diff --git a/README.md b/README.md index 04d5254..43229dc 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Launchr Launchr is a versatile CLI action runner that executes tasks defined in local or embeded yaml files across multiple runtimes: -- Short-lived container (docker) +- Short-lived container (docker or kubernetes) - Shell (host) - Golang (as plugin) @@ -15,7 +15,7 @@ Actions are defined in `action.yaml` files: - either on local filesystem: Useful for project-specific actions - or embeded as plugin: Useful for common and shared actions -You can find action examples [here](example), here and in the [documentation](docs). +You can find action examples [here](example) and in the [documentation](docs). Launchr has a plugin system that allows to extend its functionality. See [core plugins](plugins), [official plugins](https://github.com/launchrctl#org-repositories) and [documentation](docs). @@ -31,7 +31,7 @@ Launchr has a plugin system that allows to extend its functionality. See [core p ## Usage Build `launchr` from source locally. Build dependencies: -1. `go >=1.20`, see [installation guide](https://go.dev/doc/install) +1. `go >=1.24`, see [installation guide](https://go.dev/doc/install) 2. `make` Build the `launchr` tool: @@ -52,7 +52,7 @@ If you face any issues with `launchr`: ### Installation from source Build dependencies: -1. `go >=1.20`, see [installation guide](https://go.dev/doc/install) +1. `go >=1.24`, see [installation guide](https://go.dev/doc/install) 2. `make` **Global installation** @@ -81,7 +81,7 @@ bin/launchr --version ## Development -The `launchr` can be built with a `make` to `bin` directory: +The `launchr` can be built with a `make` to `bin` directory: ```shell make ``` diff --git a/app.go b/app.go index fbf6774..b6800f6 100644 --- a/app.go +++ b/app.go @@ -128,7 +128,7 @@ func (app *appImpl) init() error { // @todo consider home dir for global config. config := launchr.ConfigFromFS(os.DirFS(app.cfgDir)) actionMngr := action.NewManager( - action.WithDefaultRuntime, + action.WithDefaultRuntime(config), action.WithContainerRuntimeConfig(config, name+"_"), ) diff --git a/docs/README.md b/docs/README.md index 74debf8..95b3290 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,30 @@ # Launchr documentation -1. [Launchr](launchr.md) -2. [Launchr configuration](config.md) +1. [Built-in functionality](#built-in-functionality) 2. [Actions](actions.md) -3. [Actions Schema](actions.schema.md) \ No newline at end of file +3. [Actions Schema](actions.schema.md) +4. [Global configuration](config.md) +5. [Development](development) + +## Build plugin + +There are the following build options: +1. `-o, --output OUTPUT` - result file. If empty, application name is used. +2. `-n, --name NAME` - application name. +3. `-p, --plugin PLUGIN[@v1.1]` - use plugin in the built launchr. The flag may be specified multiple times. + ```shell + launchr build \ + -p github.com/launchrctl/launchr \ + -p github.com/launchrctl/launchr@v0.1.0 + ``` +4. `-r, --replace OLD=NEW` - replace go dependency, see [go mod edit -replace](https://go.dev/ref/mod#go-mod-edit). The flag may be specified multiple times. + + The directive may be used to replace a private repository with a local path or to set a specific version of a module. Example: + ```shell + launchr build --replace github.com/launchrctl/launchr=/path/to/local/dir + launchr build --replace github.com/launchrctl/launchr=github.com/launchrctl/launchr@v0.2.0 + ``` + +5. `-d, --debug` - include debug flags into the build to support go debugging like [Delve](https://github.com/go-delve/delve). + Without the flag, all debugging info is trimmed. +6. `-h, --help` - output help message diff --git a/docs/actions.md b/docs/actions.md index c2e9810..2c943a2 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -2,9 +2,43 @@ Actions give an ability to run arbitrary commands in containers. -## Supported container engines -1. `docker`, see [installation guide](https://docs.docker.com/engine/install/) -2. TBD +## Supported runtimes + + * Container runtimes (docker, kubernetes) + * Shell (host) + * Go language (launchr plugin) + +## Supported container runtimes + + * `docker` (default) + * `kubernetes` + +Container runtime is configured globally for all actions of type container. See [Global Configuration](config.md#container-runtime) definition. +If not specified, the action will use **Docker** as a default runtime. + +Docker or Kubernetes are not required to be installed to use containers. But the configuration must be present. + +### Docker + +Runtime tries to connect using the following configuration: + +1. `unix:///run/docker.sock` - default docker socket path +2. Via [Docker environment variables](https://docs.docker.com/reference/cli/docker/#environment-variables), see the following variables: + * `DOCKER_HOST` + * `DOCKER_API_VERSION` + * `DOCKER_CERT_PATH` + * `DOCKER_TLS_VERIFY` + +Normally, if the Docker installed locally, `unix:///run/docker.sock` will be present and used by default. +**NB!** If the docker is not running locally, you may have incorrect mounting of the paths. Consider using a flag `--remote-runtime`. + +### Kubernetes + +Runtime tries to connect using the following configuration: + +1. `~/.kube/config` - default kubectl configuration directory. +2. `KUBECONFIG` environment variable, see [kuberenetes documentation](https://kubernetes.io/docs/concepts/configuration/organize-cluster-access-kubeconfig/#the-kubeconfig-environment-variable) + Usage example: `export KUBECONFIG="/etc/rancher/k3s/k3s.yaml"` ## Action definition @@ -27,6 +61,9 @@ action: - name: optStr title: Option string description: Some additional info for option + +runtime: + type: container image: python:3.7-slim command: - python3 @@ -35,44 +72,43 @@ action: - ${ENV_VAR} ``` -See more examples of action definition in [actions.schema.md](actions.schema.md) +For more detailed specification and examples of action definition, see [actions.schema.md](actions.schema.md) ## Actions discovery -The action files must preserve a tree structure like `**/**/actions/*/action.yaml` to be discovered. +The action files must preserve a tree structure like `**/**/actions/*/action.yaml` or `actions/*/action.yaml` to be discovered. Example: ``` -foundation -└── software - └── flatcar +actions: +└── foo + └── action.yaml +foo +└── bar + └── buz └── actions - └── bump + └── waldo └── action.yaml -integration -└── application - └── bus - └── actions - └── watch - └── action.yaml -platform +bar └── actions - ├── build + ├── foo │ ├── build.sh │ └── action.yaml - └── bump + └── buz ├── bump.py └── action.yaml ``` The example structure produces the following commands: + ```shell $ launchr --help +launchr is a versatile CLI action runner that executes tasks defined in local or embeded yaml files across multiple runtimes ... -Discovered actions: - foundation.software.flatcar:bump Verb: Handles some logic - integration.application.bus:watch Verb: Handles some logic - platform:build Verb: Handles some logic - platform:bump Verb: Handles some logic +Actions: + foo foo: foo description + foo.bar.buz:waldo foo bar buz waldo: foo.bar.buz:waldo description + bar:foo bar foo: bar:foo description + bar:buz bar buz: bar:buz description ... ``` @@ -80,41 +116,63 @@ Discovered actions: To run the command simply run: ```shell -$ launchr platform:build ... +$ launchr foo:bar [args...] [--options...] ``` To get more help for the action: + + ```shell -$ launchr platform:build --help -Verb: Handles some logic +$ launchr foo:bar --help -Usage: - launchr platform:build args[1] args[2] [flags] +Foo Bar: foo:bar description -Flags: - -h, --help help for platform:build - --opt1 string Option 1: Some additional info for option - --opt2 Option 2: Some additional info for option - --opt3 int Option 3: Some additional info for option - --opt4 float Option 4: Some additional info for option - --optarr strings Option 4: Some additional info for option +Usage: + launchr foo:bar argStr [argInt] [flags] + +Arguments: + argInt int Argument Integer: This is an optional integer argument + argStr string Argument String: This is a required implicit string argument + +Options: + --optArray strings Option Array String: This is an optional array option with a default value + --optArrayBool bools Option Array Boolean: This is an optional enum option with a default value (default []) + --optBoolean Option Boolean: This is an optional boolean option with a default value + --optEnum string Option Enum: This is an optional enum option with a default value (default "enum1") + --optIP string Option String IP: This is an optional string option with a format validation (ipv4) (default "1.1.1.1") + --optInteger int Option Integer: This is an optional boolean option with a default value + --optNumber float Option Number: This is an optional number option with a default value (default 3.14) + --optString string Option String: This is an optional string option with a default value + +Action runtime options: + --entrypoint string Image Entrypoint: Overwrite the default ENTRYPOINT of the image. Example: --entrypoint "/bin/sh" + --exec Exec command: Overwrite the command of the action. Argument and options are not validated, sets container CMD directly. Example usage: --exec -- ls -lah + --no-cache No cache: Send command to build container without cache + --remote-copy-back Remote copy back: Copies the working directory back from the container. Works only if the runtime is remote. + --remote-runtime Remote runtime: Forces the container runtime to be used as remote. Copies the working directory to a container volume. Local binds are not used. + --remove-image Remove Image: Remove an image after execution of action Global Flags: - -q, --quiet log only fatal errors - -v, --verbose count log verbosity level, use -vvv DEBUG, -vv WARN, -v INFO + --log-format LogFormat log format, can be: pretty, plain or json (default pretty) + --log-level logLevelStr log level, same as -v, can be: DEBUG, INFO, WARN, ERROR or NONE (default NONE) + -q, --quiet disable output to the console + -v, --verbose count log verbosity level, use -vvvv DEBUG, -vvv INFO, -vv WARN, -v ERROR ``` -### Container environment flags +### Mounts/Volumes in container runtime - * `--entrypoint` Entrypoint: Overwrite the default ENTRYPOINT of the image - * `--exec` Exec: Overwrite CMD definition of the container - * `--no-cache` No cache: Send command to build container without cache - * `--remove-image` Remove Image: Remove an image after execution of action - * `--use-volume-wd` Use volume as a WD: Copy the working directory to a container volume and not bind local paths. Usually used with remote environments. +To follow the context on action execution, 2 mounts are passed to the execution environment: +1. `/host` +2. `/action` +If run in the local runtime (docker): -### Mounts in execution environment +1. Current working directory is mounted to `/host`, Docker equivalent `$(pwd):/host` +2. Action directory is mounted to `/action`, Docker equivalent `./action/dir:/action` -To follow the context on action execution, 2 mounts are passed to the execution environment: -1. `/host` - current working directory -2. `/action` - action directory +If run in the remote runtime (docker, kubernetes) or with a flag `--remote-runtime`: + +1. Current working directory is copied to a new volume `volume_host:/host` +2. Action directory is copied to a new volume `volume_action:/action` + +To copy back the result of the execution, use `--remote-copy-back` flag. \ No newline at end of file diff --git a/docs/actions.schema.md b/docs/actions.schema.md index 982d5c6..bafe4a8 100644 --- a/docs/actions.schema.md +++ b/docs/actions.schema.md @@ -1,88 +1,192 @@ -# Actions YAML features +# Actions YAML definition + +## Table of Contents + +1. [Action Declaration](#action-declaration) +2. [JSON Schema, Arguments and Options](#json-schema-arguments-and-options) + - [Value processors](#value-processors) + - [Examples](#examples) +3. [Templates](#templates) + - [Predefined Variables](#predefined-variables) + - [Environment Variables](#environment-variables) + - [Example](#example) +4. [Runtimes](#runtimes) + - [Container](#container) + - [Command](#command) + - [Environment Variables](#environment-variables-1) + - [Extra Hosts](#extra-hosts) + - [Build Image](#build-image) + - [Shell](#shell) + - [Script](#script) + - [Environment Variables](#environment-variables-2) + - [Plugin](#plugin) ## Action declaration -Basic action definition must have `image` and `command` to run the command in the environment. +Action has the following top-level configuration: + + * `version` - action schema version. + * `working_directory` - Working directory where the action will be executed, by default current working directory. See [Predefined variables](#predefined-variables) for possible substitutions. + * `action` (required) - declares action title, description and parameters (arguments and options). + * `runtime` (required) - declares where the action will be executed, e.g. container, shell, custom environment. ```yaml +working_directory: "{{ .current_working_dir }}" action: title: Action name description: Long description - alias: - - "alias1" - - "alias2" + +runtime: + type: container image: alpine:latest command: - ls - -lah ``` -## Arguments and options +## JSON Schema, Arguments and options + +Arguments and options are defined in `action.yaml`, parsed according to the schema and replaced on run. +Parameter declaration follows [JSON Schema](https://json-schema.org/). The declaration is the same for both. + +Both arguments and options can be required and optional, be of various types and have a default value. +The only difference is how the parameters are provided in the terminal. Arguments are positional, options are named. -Arguments and options are defined in `action.yaml`, parsed according to the schema and replaced on run. +See [examples](#examples) of how required and default are used and more complex parameter validation. -### Arguments +**Supported variable types:** +1. `string` +2. `boolean` +3. `integer` +4. `number` - float64 values +5. `array` (currently array of 1 supported type) +See [JSON Schema Reference](https://json-schema.org/understanding-json-schema/reference) for better understanding of +how to use types, format, enums and other useful features. + +### Value processors + +Value processors are handlers applied to action parameters (arguments and options) to manipulate the data. + +Launchr processors: + * `config.GetValue` (core) + * `keyring.GetKeyValue` (keyring plugin) + +Usage example: ```yaml -... - arguments: - - name: myArg1 - title: Argument 1 - description: Some additional info for arg - - name: MyArg2 - title: Argument 2 - description: Some additional info for arg -... + # ... + options: + - name: string + process: + - processor: config.GetValue + options: + path: my.string ``` -### Options +Plugins may provide their own processors. See [Development / Plugin - Value processors](development/plugin.md#value-processors) for an example how to implement your own. + +### Examples ```yaml -... +action: + # ... + # Arguments declaration + arguments: + - name: myArg1 + title: Argument 1 + description: This is a required argument of implicit type "string" + required: true + + - name: MyArg2 + title: Argument 2 - Integer + description: | + This is a required argument of type int with a default value. + It can be omitted, default value is used. + type: integer + required: true + default: 42 + + - name: MyArg3 + title: Argument 3 - Enum string + type: string + enum: [enum1, enum2] + description: | + This is an optional argument without a default value of type enum. + Only enum values are allowed. + It can be omitted, nil value is used. + Since arguments are positional in the terminal, MyArg2 must be provided. + + # Options declaration options: - name: optStr + title: Option default string + default: "" + description: | + This is an option of implicit type "string". + It can be omitted, empty string is used. + + - name: optStrNil title: Option string - description: Some additional info for option + description: | + This is an option of implicit type "string". + It can be omitted, no default value, nil value is used. + - name: optBool title: Option bool - description: Some additional info for option type: boolean + required: true + description: | + This is a required option of type boolean. + Without a default value, it must be always provided in the terminal. + - name: optInt title: Option int - description: Some additional info for option type: integer + default: 42 + description: | + This is a required option of type integer. + It may be omitted, default value is used. + - name: optNum - title: Option float - description: Some additional info for option + title: Option number type: number + - name: optArr title: Option array - description: Some additional info for option type: array -... + items: # Optional array type declaration. `string` is used by default. + type: string + enum: [enum1, enum2] + default: [] + description: | + This is an optional option of type array. + It may be omitted, default value is used. + + - name: optenum + title: Option enum + type: string + enum: [enum1, enum2] + default: enum1 + description: | + This is an optional option of type enum. By default `enum1` is used. + Only enum values may allowed. This is validated by JSON Schema. + + - name: optip + title: Option IP string + type: string + format: "ipv4" + default: "1.1.1.1" + description: | + This is an option of type string with json schema validation to check it's valid IP address +# ... ``` -### Variable types - -Arguments and options values declaration follows [JSON Schema](https://json-schema.org/) (not yet actually). - -**Supported types:** -1. `string` -2. `boolean` -3. `integer` -4. `number` - float64 values -5. `array` (currently array of strings only) - -Arguments can only be of type `string` and are always required. - -## Templating of action file +## Templates The action provides basic templating for all file based on arguments, options and environment variables. -### Arguments and options - -For templating, standard Go templating engine is used. -Refer to [documentation](https://pkg.go.dev/text/template). +For templating, the standard Go templating engine is used. +Refer to [the library documentation](https://pkg.go.dev/text/template) for usage examples. Arguments and Options are available by their machine names - `{{ .myArg1 }}`, `{{ .optStr }}`, `{{ .optArr }}`, etc. @@ -90,12 +194,13 @@ Arguments and Options are available by their machine names - `{{ .myArg1 }}`, `{ 1. `current_uid` - current user ID. In Windows environment set to 0. 2. `current_gid` - current group ID. In Windows environment set to 0. -3. `current_working_dir` - app working directory. +3. `current_working_dir` - current app working directory. 4. `actions_base_dir` - actions base directory where the action was found. By default, current working directory, but other paths may be provided by plugins. 5. `action_dir` - directory of the action file. +6. `current_bin` - path to the currently executed command, like $0 in bash. -### Environment variables: +### Environment variables | __Expression__ | __Meaning__ | |------------------|--------------------------------------------| @@ -106,49 +211,85 @@ Arguments and Options are available by their machine names - `{{ .myArg1 }}`, `{ ### Example ```yaml -... +action: + # ... arguments: - name: myArg1 - name: MyArg2 -... options: - name: optStr - name: optBool -... + +runtime: + type: container image: {{ .optStr }}:latest command: - {{ .myArg1 }} {{ .MyArg2 }} - {{ .optBool }} ``` -## Command +## Runtimes + +Action can be executed in different runtime environments. This section covers their declaration. + +### Container + +Container runtime executes the action in a container. Basic definition must have `type`, `image` and `command` to run an action. + +Here is an example: + +```yaml +# ... +runtime: + type: container + image: alpine:latest + env: + ENV1: val1 + build: + context: ./ + extra_hosts: + - "host.docker.internal:host-gateway" + - "example.com:127.0.0.1" + command: + - cat + - /etc/hosts +``` + +A more detailed definition of each property can be found below. + +#### Command Command can be written in 2 forms - as a string and as an array: ```yaml ... +runtime: + type: container command: ls -... ``` ```yaml ... +runtime: + type: container command: ["ls", "-al"] -... ``` ```yaml ... +runtime: + type: container command: - ls - -al -... ``` It is recommended to use array form for multiple arguments. -## Environment variables +#### Environment variables -To pass environment variables to the execution environment, add `env` section (outside of `build section`): +To pass environment variables to the execution environment, add `env` section: ```yaml +runtime: + type: container env: - ENV1=val1 - ENV2=$ENV2 @@ -156,27 +297,30 @@ To pass environment variables to the execution environment, add `env` section (o ``` Or in map style: ```yaml +runtime: + type: container env: ENV1: val1 ENV2: $ENV2 ENV3: ${ENV3} ``` + +
+Example output: + For instance: ```yaml action: title: Test - description: Test - image: test:latest + +runtime: + type: container + image: alpine:latest env: ACTION_ENV: some_value - build: - context: ./ - args: - USER_ID: {{ .current_uid }} - GROUP_ID: {{ .current_gid }} command: - - sh - - /action/main.sh + - echo + - $$ACTION_ENV ``` Renders as: ``` @@ -188,31 +332,36 @@ Or action: title: Test description: Test - image: test:latest + +runtime: + type: container + image: alpine:latest env: ACTION_ENV: ${HOST_ENV} - build: - context: ./ - args: - USER_ID: {{ .current_uid }} - GROUP_ID: {{ .current_gid }} command: - - sh - - /action/main.sh + - echo + - $$ACTION_ENV ``` Renders as: ``` + echo 'ACTION_ENV=var_value_from_host' ACTION_ENV=var_value_from_host ``` +
-## Extra hosts +#### Extra hosts Extra hosts may be passed to be resolved inside the action environment: ```yaml +runtime: + type: container + image: alpine:latest extra_hosts: - "host.docker.internal:host-gateway" - "example.com:127.0.0.1" + command: + - cat + - /etc/hosts ``` Renders `/etc/hosts` as: ``` @@ -222,20 +371,24 @@ Renders `/etc/hosts` as: 127.0.0.1 example.com ``` -## Build image +#### Build image Images may be built in place. `build` directive describes the working directory on build. Image name is used to tag the built image. Short declaration: ```yaml +runtime: + type: container image: my/image:version build: ./ # Build working directory -... + # ... ``` Long declaration: ```yaml +runtime: + type: container image: my/image:version build: context: ./ @@ -243,14 +396,17 @@ Long declaration: args: arg1: val1 arg2: val2 -... + # ... ``` 1. `context` - build working directory 2. `buildfile` - build file relative to context directory, can't be outside of the `context` directory. 3. `tags` - tags for a build image 4. `args` - arguments passed to the `buildfile` can be used in Dockerfile, such as: + ```yaml +runtime: + # ... build: context: ./ args: @@ -258,6 +414,8 @@ Long declaration: GROUP_ID: {{ .current_gid }} USER_NAME: plasma ``` + + Can be used as: ``` FROM alpine:latest @@ -274,3 +432,73 @@ plasma + id uid=1000(plasma) gid=1000(plasma) groups=1000(plasma) ``` + +### Shell + +Shell runtime executes an action on the host. Currently on Unix hosts are supported. +Basic definition must have `type` and `script` to run an action. + +```yaml +# ... +runtime: + type: shell + script: | + date + pwd + whoami + env +``` + +A more detailed definition of each property can be found below. + +#### Script + +The script is executed in the default user shell provided by `$SHELL` environment variable. If it's empty, `/bin/bash` is used by default. +Compared to `container` runtime with a command defined as an array, here we can define a multiline script: + +```yaml +# ... +runtime: + type: shell + script: | + date + pwd + whoami + env +``` + +#### Environment variables + +To pass environment variables to the execution environment, add `env` section. They work exactly the same as in container. +**NB!** If you need to use an environment variable in the script, you must escape it with a double `$$` like `$$MY_ENV`. +If not escaped, the variable will be replaced during templating and not during the execution. That may lead to an unwanted result. + +```yaml +# ... +runtime: + type: shell + env: + MY_VAR1: my_env + script: | + env + echo "$$MY_VAR1" # Prints "my_env" + echo "$MY_VAR1" # Prints empty string +``` + +### Plugin + +The `plugin` type is used to write a custom runtime using a go code. + +```yaml +# ... +runtime: + type: plugin +``` + +Because plugins don't require additional runtime parameters, they can be declared using this shortened syntax: + +```yaml +runtime: plugin +``` + +See how to implement plugin actions in [Development - Plugin](development/plugin.md#action-plugin) diff --git a/docs/config.md b/docs/config.md index ed94d33..cd32705 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,27 +1,53 @@ # Global configuration Launchr provides a way to make a global configuration for all actions. -The global configuration is read from directory `.launchr`. It should have `config.yaml` file. +The global configuration is read from directory `.launchr`. It should have `config.yaml` file. +If the application was build with a different name, the directory will be named accordingly `.my_app_name`. +## Table of contents -## Beautify action names via config file +* [Container runtime](#container-runtime) +* [Modify action names after discovery](#modify-action-names-after-discovery) +* [Build images](#build-images) +* [Action image build hash sums](#action-image-build-hash-sums) + +## Container runtime + +To change the default container runtime: + +```yaml +# ... +container: + runtime: kubernetes +# ... +``` + +If not specified, the action will use **Docker** as a default runtime. + +## Modify action names after discovery It's possible to replace parts of the original action ID to receive prettier naming. ```yaml +# ... launchrctl: actions_naming: - - search: ".roles." + - search: ".replaceme." replace: "." - search: "_" replace: "-" +# ... ``` +In the given example, if an action is located in `foo/replaceme/bar_buz/actions/fred/action.yaml` + * Before: `foo.replaceme.bar_buz:fred` + * After: `foo.bar-buz:fred` + ## Build images Common images to be used by actions can be provided with the following schema: ```yaml -... +# ... images: my/image:version: context: ./ @@ -35,19 +61,19 @@ images: args: arg1: val1 arg2: val2 -... +# ... ``` Image definition search process: -1. Check if image already exists in Docker +1. Check if an image already exists in a container registry 2. Check action build definition in `action.yaml` 3. Check global configuration for image name or tags -## Action build hash sum +## Action image build hash sums After first successful build, `actions.sum` file is created in `.launchr` directory. -It stores action directory hash sum of all actions to determine if an image rebuild is required on the next run. +It stores a hash sum of an action directory for all actions to determine if an image rebuild is required on the next run. Checking sum difference: 1. Check if `actions.sum` file exists diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..f18115e --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,9 @@ +# Development + +This section covers the main parts of Launchr's development: + +1. [Plugins](plugin.md) - Covers the plugin system of Launchr, which allows adding new functionalities + through custom plugins. Plugins can provide additional actions, runtimes, and features to enhance Launchr's + capabilities. +2. [Service](service.md) - Describes the service implementation and dependency injection system used by + Launchr to manage components and their dependencies. diff --git a/docs/development/plugin.md b/docs/development/plugin.md new file mode 100644 index 0000000..834d5c1 --- /dev/null +++ b/docs/development/plugin.md @@ -0,0 +1,190 @@ +# Plugins + +Plugins is a way to extend launchr functionality. +The main difference from actions is that plugins must be written in `go` and run natively on a host machine. + +## Available plugins + +Available plugin types: + +1. `OnAppInitPlugin` - runs on the application init stage. +2. `ActionDiscoveryPlugin` - discovers and provides actions +3. `CobraPlugin` - adds cobra cli functionality. Use `ActionDiscoveryPlugin` to add new actions. +4. `PersistentPreRunPlugin` - runs before cobra cobra command when all arguments are parsed. +5. `GeneratePlugin` - generates arbitrarily required files before build. + +Plugin implementation examples: + +1. [Default plugins](../../plugins) +2. [Keyring](https://github.com/launchrctl/keyring) +3. [Compose](https://github.com/launchrctl/compose) +4. [Web](https://github.com/launchrctl/web) +5. [Plugin Example - Runtime Action](https://github.com/launchrctl/plugin-example-plugin-runtime) +6. [Plugin Example - Container Runtime](https://github.com/launchrctl/plugin-example-container-runtime) + +## Plugin declaration + +A plugin must implement `launchr.Plugin` interface. Here is an example: + +```go +package example + +import ( + "github.com/launchrctl/launchr" +) + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +type Plugin struct{} + +func (p *Plugin) PluginInfo() launchr.PluginInfo { + return launchr.PluginInfo{} +} +``` + +## Action plugin + +To implement an action using a plugin, define an action file. Here we create 2 actions: + +
+action.login.yaml: + +```yaml +runtime: plugin +action: + title: "Keyring: Log in" + description: >- + Logs in to services like git, docker, etc. + options: + - name: url + title: URL + default: "" + - name: username + title: Username + default: "" + - name: password + title: Password + default: "" +``` + +
+ +
+action.logout.yaml: + +```yaml +runtime: plugin +action: + title: "Keyring: Log out" + description: >- + Logs out from a service + arguments: + - name: url + title: URL + description: URL to log out + minLength: 1 + options: + - name: all + title: All + description: Logs out from all services + type: boolean + default: false +``` + +
+ +The important thing here is - `runtime: plugin`. +Next embed the actions into the code using `embed`. And define actions and their runtime in `DiscoverActions` implementing plugin `launchr.ActionDiscoveryPlugin`. + +```go + +import ( + _ "embed" + + "github.com/launchrctl/launchr" +) + +var ( + //go:embed action.login.yaml + actionLoginYaml []byte + //go:embed action.logout.yaml + actionLogoutYaml []byte +) + +func init() { + launchr.RegisterPlugin(&Plugin{}) +} + +// Plugin is [launchr.Plugin] plugin providing a keyring. +type Plugin struct {} + +// DiscoverActions implements [launchr.ActionDiscoveryPlugin] interface. +func (p *Plugin) DiscoverActions(ctx context.Context) ([]*action.Action, error) { + // Action login. + loginCmd := action.NewFromYAML("keyring:login", actionLoginYaml) + loginCmd.SetRuntime(action.NewFnRuntime(func(_ context.Context, a *action.Action) error { + input := a.Input() + creds := CredentialsItem{ + Username: input.Opt("username").(string), + Password: input.Opt("password").(string), + URL: input.Opt("url").(string), + } + return nil + })) + + // Action logout. + logoutCmd := action.NewFromYAML("keyring:logout", actionLogoutYaml) + logoutCmd.SetRuntime(action.NewFnRuntime(func(_ context.Context, a *action.Action) error { + input := a.Input() + all := input.Opt("all").(bool) + if all == input.IsArgChanged("url") { + return fmt.Errorf("please, either provide an URL or use --all flag") + } + url, _ := input.Arg("url").(string) + return logout(p.k, url, all) + })) + + return []*action.Action{ + loginCmd, + logoutCmd, + }, nil +} +``` + +## Value processors + +Value processors are handlers applied to action parameters (arguments and options) to manipulate the data. + +Define a processor using a generic `action.GenericValueProcessorOptions` or implement a fully custom. +```go +type procTestReplaceOptions = *GenericValueProcessorOptions[struct { + O string `yaml:"old" validate:"not-empty"` + N string `yaml:"new"` +}] +``` + +Add the processor to the Action Manager: + +```go +var am action.Manager +app.GetService(&am) + +procReplace := GenericValueProcessor[procTestReplaceOptions]{ + Types: []jsonschema.Type{jsonschema.String}, + Fn: func(v any, opts procTestReplaceOptions, _ ValueProcessorContext) (any, error) { + return strings.Replace(v.(string), opts.Fields.O, opts.Fields.N, -1), nil + }, +} + +am.AddValueProcessor("test.replace", procReplace) +``` + +The generic `action.GenericValueProcessorOptions` gives a few benefits: +1. Property validation using `validate:"constraint1 constraint2"`. Available constraints: + * `not-empty` +2. Parsing of the properties +3. Typed options when implementing the processor + +See [build-in processors](../../plugins/builtinprocessors/plugin.go) for an example how to implement a value processor. diff --git a/docs/development/service.md b/docs/development/service.md new file mode 100644 index 0000000..2adc7ed --- /dev/null +++ b/docs/development/service.md @@ -0,0 +1,64 @@ +# Services + +Service is a launchr interface to share functionality between plugins. It is a simple Dependency Injection mechanism. + +In launchr there are several core services: +1. `launchr.Config` - stores global launchr configuration, see [config documentation](../config.md). +2. `action.Manager` - manages available actions, see [actions documentation](../actions.md) +3. `launchr.PluginManager` - stores all registered plugins. + +Services available with plugins: +1. [Keyring](https://github.com/launchrctl/keyring) - provides functionality to store credentials. + +### How to use services + +Service can be retrieved from the `launchr.App`. It is important to use a unique interface to retrieve the specific service +from the app. + +```go +package example + +import ( + "github.com/launchrctl/launchr" +) + +// Get a service from the App. +func (p *Plugin) OnAppInit(app launchr.App) error { + var cfg launchr.Config + app.GetService(&cfg) // Pass a pointer to init the value. + return nil +} +``` + +### How to implement a service +A service must implement `launchr.Service` interface. Here is an example: + +```go +package example + +import ( + "github.com/launchrctl/launchr" +) + +// Define a service and implement service interface. +// It is important to have a unique interface, the service is identified by it in launchr.GetService(). +type ExampleService interface { + launchr.Service // Inherit launchr.Service + // Provide other methods if needed. +} + +type exampleSrvImpl struct { + // ... +} + +func (ex *exampleSrvImpl) ServiceInfo() launchr.ServiceInfo { + return launchr.ServiceInfo{} +} + +// Register a service inside launchr. +func (p *Plugin) OnAppInit(app launchr.App) error { + srv := &exampleSrvImpl{} + app.AddService(srv) + return nil +} +``` diff --git a/docs/launchr.md b/docs/launchr.md deleted file mode 100644 index 63163d8..0000000 --- a/docs/launchr.md +++ /dev/null @@ -1,129 +0,0 @@ -# Launchr - -## Build plugin - -There are the following build options: -1. `-o, --output OUTPUT` - result file. If empty, application name is used. -2. `-n, --name NAME` - application name. -3. `-p, --plugin PLUGIN[@v1.1]` - use plugin in the built launchr. The flag may be specified multiple times. - ```shell - launchr build \ - -p github.com/launchrctl/launchr \ - -p github.com/launchrctl/launchr@v0.1.0 - ``` -4. `-r, --replace OLD=NEW` - replace go dependency, see [go mod edit -replace](https://go.dev/ref/mod#go-mod-edit). The flag may be specified multiple times. - - The directive may be used to replace a private repository with a local path or to set a specific version of a module. Example: - ```shell - launchr build --replace github.com/launchrctl/launchr=/path/to/local/dir - launchr build --replace github.com/launchrctl/launchr=github.com/launchrctl/launchr@v0.2.0 - ``` - -5. `-d, --debug` - include debug flags into the build to support go debugging like [Delve](https://github.com/go-delve/delve). - Without the flag, all debugging info is trimmed. -6. `-h, --help` - output help message - -## Plugins - -Plugins is a way to extend launchr functionality. -The main difference from actions is that plugins must be written in `go` and run natively on a host machine. - -A plugin must implement `launchr.Plugin` interface. Here is an example: -```go -package example - -import ( - ... - "github.com/launchrctl/launchr" - ... -) - -func init() { - launchr.RegisterPlugin(&Plugin{}) -} - -type Plugin struct {} - -func (p *Plugin) PluginInfo() launchr.PluginInfo { - return launchr.PluginInfo{} -} -``` - -Available plugin types: -1. `OnAppInitPlugin` -2. `CobraPlugin` -3. `GeneratePlugin` - -Plugin implementation examples: -1. [yamldiscovery](../plugins/yamldiscovery) -2. [Keyring](https://github.com/launchrctl/keyring) -2. [Compose](https://github.com/launchrctl/compose) - -## Services - -Service is a launchr interface to share functionality between plugins. It is a simple Dependency Injection mechanism. - -In launchr there are several core services: -1. `launchr.Config` - stores global launchr configuration, see [config documentation](config.global.md). -2. `action.Manager` - manages available actions, see [actions documentation](actions.md) -3. `launchr.PluginManager` - stores all registered plugins. - -Services available with plugins: -1. [Keyring](https://github.com/launchrctl/keyring) - provides functionality to store passwords. - -### How to use services - -Service can be retrieved from the `launchr.App`. It is important to use a unique interface to retrieve the specific service -from the app. - -```go -package example - -import ( - ... - "github.com/launchrctl/launchr" - ... -) - -// Get a service from the App. -func (p *Plugin) OnAppInit(app launchr.App) error { - var cfg launchr.Config - app.GetService(&cfg) // Pass a pointer to init the value. - return nil -} -``` - -### How to implement a service -A service must implement `launchr.Service` interface. Here is an example: - -```go -package example - -import ( - ... - "github.com/launchrctl/launchr" - ... -) - -// Define a service and implement service interface. -// It is important to have a unique interface, the service is identified by it in launchr.GetService(). -type ExampleService interface { - launchr.Service // Inherit launchr.Service - // Provide other methods if needed. -} - -type exampleSrvImpl struct { - // ... -} - -func (ex *exampleSrvImpl) ServiceInfo() launchr.ServiceInfo { - return launchr.ServiceInfo{} -} - -// Register a service inside launchr. -func (p *Plugin) OnAppInit(app launchr.App) error { - srv := &exampleSrvImpl{} - app.AddService(srv) - return nil -} -``` \ No newline at end of file diff --git a/example/actions/platform/actions/build/action.yaml b/example/actions/platform/actions/build/action.yaml index 3a0441d..628991c 100644 --- a/example/actions/platform/actions/build/action.yaml +++ b/example/actions/platform/actions/build/action.yaml @@ -1,61 +1,65 @@ working_directory: "{{ .current_working_dir }}" action: - title: Verb - description: Handles some logic + title: Platform build + description: platform:build description arguments: - - name: arg1 - title: Argument 1 - description: Some additional info for arg + - name: argStr + title: Argument String + description: This is a required implicit string argument required: true - - name: arg2 - title: Argument 2 - description: Some additional info for arg + - name: argInt + title: Argument Integer + type: integer + description: This is an optional integer argument options: - - name: opt1 - title: Option 1 - description: Some additional info for option + - name: optString + title: Option String + description: This is an optional string option with a default value default: "" - - name: opt2 - title: Option 2 - description: Some additional info for option + - name: optBoolean + title: Option Boolean type: boolean default: false - - name: opt3 - title: Option 3 - description: Some additional info for option + description: This is an optional boolean option with a default value + - name: optInteger + title: Option Integer type: integer default: 0 - - name: opt4 - title: Option 4 - description: Some additional info for option + description: This is an optional boolean option with a default value + - name: optNumber + title: Option Number type: number default: 3.14 - - name: optarr - title: Option 4 - description: Some additional info for option + description: This is an optional number option with a default value + - name: optArray + title: Option Array String type: array default: [] - - name: optenum - title: Option 5 + description: This is an optional array option with a default value + - name: optEnum + title: Option Enum type: string enum: [enum1, enum2] default: enum1 - - name: optarrbool - title: Option 6 + description: This is an optional enum option with a default value + - name: optArrayBool + title: Option Array Boolean type: array default: [] items: type: boolean - - name: optip - title: Option 7 + description: This is an optional enum option with a default value + - name: optIP + title: Option String IP type: string format: "ipv4" default: "1.1.1.1" + description: This is an optional string option with a format validation (ipv4) runtime: type: container # image: python:3.7-slim image: ubuntu # command: python3 {{ .opt4 }} -# command: ["sh", "-c", "for i in $(seq 60); do echo $$i; sleep 1; done"] - command: /bin/bash + command: ["sh", "-c", "for i in $(seq 60); do if [ $((i % 2)) -eq 0 ]; then echo \"stdout: $$i\"; else echo \"stderr: $$i\" >&2; fi; sleep 1; done"] +# command: /bin/bash diff --git a/go.mod b/go.mod index 659d5ab..028714f 100644 --- a/go.mod +++ b/go.mod @@ -1,12 +1,13 @@ module github.com/launchrctl/launchr -go 1.23.2 +go 1.24.0 -toolchain go1.23.3 +toolchain go1.24.1 require ( - github.com/docker/docker v27.5.1+incompatible + github.com/docker/docker v28.1.1+incompatible github.com/knadh/koanf v1.5.0 + github.com/moby/go-archive v0.1.0 github.com/moby/sys/signal v0.7.1 github.com/moby/term v0.5.2 github.com/pterm/pterm v0.12.80 @@ -14,18 +15,20 @@ require ( github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/stretchr/testify v1.10.0 - go.uber.org/mock v0.5.0 - golang.org/x/mod v0.23.0 - golang.org/x/sys v0.30.0 - golang.org/x/text v0.22.0 + go.uber.org/mock v0.5.1 + golang.org/x/mod v0.24.0 + golang.org/x/sys v0.32.0 + golang.org/x/text v0.24.0 gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.33.0 + k8s.io/apimachinery v0.33.0 + k8s.io/client-go v0.33.0 ) require ( atomicgo.dev/cursor v0.2.0 // indirect atomicgo.dev/keyboard v0.2.9 // indirect atomicgo.dev/schedule v0.1.0 // indirect - github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/containerd/console v1.0.4 // indirect @@ -34,43 +37,72 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-connections v0.5.0 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fxamacker/cbor/v2 v2.8.0 // indirect github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-openapi/jsonpointer v0.21.1 // indirect + github.com/go-openapi/jsonreference v0.21.0 // indirect + github.com/go-openapi/swag v0.23.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.6.9 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/gookit/color v1.5.4 // indirect + github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect - github.com/klauspost/compress v1.17.11 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/compress v1.18.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect + github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/spdystream v0.5.0 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect github.com/moby/sys/sequential v0.6.0 // indirect - github.com/moby/sys/user v0.3.0 // indirect + github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.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/opencontainers/go-digest v1.0.0 // indirect - github.com/opencontainers/image-spec v1.1.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect + github.com/x448/float16 v0.8.4 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 // indirect - go.opentelemetry.io/otel v1.34.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect + go.opentelemetry.io/otel v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.31.0 // indirect - go.opentelemetry.io/otel/metric v1.34.0 // indirect - go.opentelemetry.io/otel/sdk v1.31.0 // indirect - go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.opentelemetry.io/otel/metric v1.35.0 // indirect + go.opentelemetry.io/otel/trace v1.35.0 // indirect golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect - golang.org/x/term v0.29.0 // indirect - golang.org/x/time v0.7.0 // indirect + golang.org/x/net v0.39.0 // indirect + golang.org/x/oauth2 v0.29.0 // indirect + golang.org/x/term v0.31.0 // indirect + golang.org/x/time v0.11.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20241007155032-5fefd90f89a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20241007155032-5fefd90f89a9 // indirect - gotest.tools/v3 v3.5.1 // indirect + google.golang.org/protobuf v1.36.6 // indirect + gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff // indirect + k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v4 v4.7.0 // indirect + sigs.k8s.io/yaml v1.4.0 // indirect ) diff --git a/go.sum b/go.sum index 51587de..0185208 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +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/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4= github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw= @@ -74,13 +76,15 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/docker v27.5.1+incompatible h1:4PYU5dnBYqRQi0294d1FBECqT9ECWeQAIfE8q4YnPY8= -github.com/docker/docker v27.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker v28.1.1+incompatible h1:49M11BFLsVO1gxY9UX9p/zwkE/rswggs8AdFmXQw51I= +github.com/docker/docker v28.1.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -93,6 +97,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fxamacker/cbor/v2 v2.8.0 h1:fFtUGXUzXPHTIUdne5+zzMPTfffl3RD5qYnkY40vtxU= +github.com/fxamacker/cbor/v2 v2.8.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= @@ -106,7 +112,15 @@ github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-openapi/jsonpointer v0.21.1 h1:whnzv/pNXtK2FbX/W9yJfRmE2gsmkfahjMKB0fZvcic= +github.com/go-openapi/jsonpointer v0.21.1/go.mod h1:50I1STOfbY1ycR8jGz8DaMeLCdXiI6aDteEdRNNzpdk= +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.1 h1:lpsStH0n2ittzTnbaSloVZLuB5+fvSY/+hnagBjSNZU= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +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/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= @@ -130,6 +144,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= +github.com/google/gnostic-models v0.6.9 h1:MU/8wDLif2qCXZmzncUQ/BOfxWfthHi63KqpoNbWqVw= +github.com/google/gnostic-models v0.6.9/go.mod h1:CiWsm0s6BSQd1hRn8/QmxqB6BesYcbSZxsz9b0KuDBw= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= @@ -139,9 +155,12 @@ github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= -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-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db h1:097atOisP2aRj7vFgYQBbFN4U4JNXUNYpxael3UzMyo= +github.com/google/pprof v0.0.0-20241029153458-d1b30febd7db/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 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= @@ -149,6 +168,8 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= @@ -196,16 +217,20 @@ github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHW github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc= github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg= +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/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +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/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= 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= -github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= @@ -226,6 +251,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/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= @@ -258,32 +285,49 @@ 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/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +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/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= github.com/moby/sys/signal v0.7.1 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0= github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8= -github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo= -github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= 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 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= +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/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/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +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/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= +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= -github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pelletier/go-toml v1.7.0 h1:7utD74fnzVc/cpcyy8sjrlFr5vYpypUixARcHIMIGuI= @@ -351,6 +395,8 @@ github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -359,6 +405,8 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +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/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= @@ -371,25 +419,27 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3 go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0 h1:CV7UdSGJt/Ao6Gp4CXckLxVRRsRgDHoI8XjbL3PDl8s= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.59.0/go.mod h1:FRmFuRJfag1IZ2dPkHnEoSFVgTVPUd2qf5Vi69hLb8I= -go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= -go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= +go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= +go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= 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.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= -go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= -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.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= -go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= +go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= +go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= +go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= +go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= +go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= +go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= +go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs= +go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= -go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= -go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +go.uber.org/mock v0.5.1 h1:ASgazW/qBmR+A32MYFDB6E2POoTgOwT509VP0CT/fjs= +go.uber.org/mock v0.5.1/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= @@ -411,8 +461,8 @@ golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.23.0 h1:Zb7khfcRGKk+kqfxFaP5tZqCnDZMjC5VtUBs87Hr6QM= -golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= +golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -432,11 +482,13 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b golang.org/x/net v0.0.0-20210410081132-afb366fc7cd1/go.mod h1:9tjilg8BloeKEkVJvy7fQ90B1CfIiPueXVOjqfkSzI8= 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.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= -golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= +golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.29.0 h1:WdYw2tdTK1S8olAzWHdgeqfy+Mtm9XNhv/xJsY65d98= +golang.org/x/oauth2 v0.29.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -487,15 +539,15 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20= +golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o= +golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -505,11 +557,11 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= +golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= -golang.org/x/time v0.7.0 h1:ntUhktv3OPE6TgYxXWv9vKvUSJyIFJlyohwbkEwPrKQ= -golang.org/x/time v0.7.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= @@ -523,6 +575,8 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 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= @@ -560,14 +614,18 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -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.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/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/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -581,8 +639,29 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/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.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= -gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +k8s.io/api v0.33.0 h1:yTgZVn1XEe6opVpP1FylmNrIFWuDqe2H0V8CT5gxfIU= +k8s.io/api v0.33.0/go.mod h1:CTO61ECK/KU7haa3qq8sarQ0biLq2ju405IZAd9zsiM= +k8s.io/apimachinery v0.33.0 h1:1a6kHrJxb2hs4t8EE5wuR/WxKDwGN1FKH3JvDtA0CIQ= +k8s.io/apimachinery v0.33.0/go.mod h1:BHW0YOu7n22fFv/JkYOEfkUYNRN0fj0BlvMFWA7b+SM= +k8s.io/client-go v0.33.0 h1:UASR0sAYVUzs2kYuKn/ZakZlcs2bEHaizrrHUZg0G98= +k8s.io/client-go v0.33.0/go.mod h1:kGkd+l/gNGg8GYWAPr0xF1rRKvVWvzh9vmZAMXtaKOg= +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-20250318190949-c8a335a9a2ff h1:/usPimJzUKKu+m+TE36gUyGcf03XZEP0ZIKgKj35LS4= +k8s.io/kube-openapi v0.0.0-20250318190949-c8a335a9a2ff/go.mod h1:5jIi+8yX4RIb8wk3XwBo5Pq2ccx4FP10ohkbSKCZoK8= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e h1:KqK5c/ghOm8xkHYhlodbp6i6+r+ChV2vuAuVRdFbLro= +k8s.io/utils v0.0.0-20250321185631-1f6e0b77f77e/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/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= +sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc= +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/launchr/filepath.go b/internal/launchr/filepath.go index a594108..d45e9f2 100644 --- a/internal/launchr/filepath.go +++ b/internal/launchr/filepath.go @@ -40,7 +40,8 @@ func FsRealpath(fsys fs.FS) string { return MustAbs(fspath) } } - if typeString(fsys) == "*fs.subFS" { + switch typeString(fsys) { + case "*fs.subFS": pfs := privateFieldValue[fs.FS](fsys, "fsys") dir := privateFieldValue[string](fsys, "dir") path := FsRealpath(pfs) diff --git a/internal/launchr/streams.go b/internal/launchr/streams.go index 6001c50..ef45c34 100644 --- a/internal/launchr/streams.go +++ b/internal/launchr/streams.go @@ -3,8 +3,6 @@ package launchr import ( "errors" "io" - "os" - "strings" mobyterm "github.com/moby/term" ) @@ -16,14 +14,16 @@ type Streams interface { // Out returns the writer used for stdout. Out() *Out // Err returns the writer used for stderr. - Err() io.Writer + Err() *Out io.Closer } type commonStream struct { - fd uintptr + fd uintptr + state *mobyterm.State + + isDiscard bool isTerminal bool - state *mobyterm.State } // FD returns the file descriptor number for this stream. @@ -31,6 +31,11 @@ func (s *commonStream) FD() uintptr { return s.fd } +// IsDiscard returns if read/write is discarded. +func (s *commonStream) IsDiscard() bool { + return s.isDiscard +} + // IsTerminal returns true if this stream is connected to a terminal. func (s *commonStream) IsTerminal() bool { return s.isTerminal @@ -44,6 +49,7 @@ func (s *commonStream) RestoreTerminal() { } // SetIsTerminal sets the boolean used for isTerminal. +// Used for tests only. func (s *commonStream) SetIsTerminal(isTerminal bool) { s.isTerminal = isTerminal } @@ -55,12 +61,16 @@ type Out struct { } func (o *Out) Write(p []byte) (int, error) { + if o.out == nil { + // Discard. + return len(p), nil + } return o.out.Write(p) } // SetRawTerminal sets raw mode on the input terminal. func (o *Out) SetRawTerminal() (err error) { - if os.Getenv("NORAW") != "" || !o.commonStream.isTerminal { + if !o.commonStream.IsTerminal() { return nil } o.commonStream.state, err = mobyterm.SetRawTerminalOutput(o.commonStream.fd) @@ -87,12 +97,25 @@ func (o *Out) Writer() io.Writer { return o.out } -// NewOut returns a new [Out] object from a [io.Writer]. +// Close implement [io.Closer] +func (o *Out) Close() error { + if out, ok := o.out.(io.Closer); ok { + return out.Close() + } + return nil +} + +// NewOut returns a new [Out] object from an [io.Writer]. func NewOut(out io.Writer) *Out { fd, isTerminal := mobyterm.GetFdInfo(out) + isDiscard := out == nil return &Out{ - commonStream: commonStream{fd: fd, isTerminal: isTerminal}, - out: out, + commonStream: commonStream{ + fd: fd, + isTerminal: isTerminal, + isDiscard: isDiscard, + }, + out: out, } } @@ -103,32 +126,30 @@ type In struct { } func (i *In) Read(p []byte) (int, error) { + if i.in == nil { + // Discard. + return 0, io.EOF + } return i.in.Read(p) } // Close implements the [io.Closer] interface. func (i *In) Close() error { + if i.in == nil { + return nil + } return i.in.Close() } // SetRawTerminal sets raw mode on the input terminal. func (i *In) SetRawTerminal() (err error) { - if os.Getenv("NORAW") != "" || !i.commonStream.isTerminal { + if !i.commonStream.IsTerminal() { return nil } i.commonStream.state, err = mobyterm.SetRawTerminal(i.commonStream.fd) return err } -// CheckTty checks if we are trying to attach to a container tty -// from a non-tty client input stream, and if so, returns an error. -func (i *In) CheckTty(attachStdin, ttyMode bool) error { - if ttyMode && attachStdin && !i.isTerminal { - return errors.New("the input device is not a TTY") - } - return nil -} - // Reader returns the wrapped reader. func (i *In) Reader() io.ReadCloser { return i.in @@ -137,50 +158,42 @@ func (i *In) Reader() io.ReadCloser { // NewIn returns a new [In] object from a [io.ReadCloser] func NewIn(in io.ReadCloser) *In { fd, isTerminal := mobyterm.GetFdInfo(in) - return &In{commonStream: commonStream{fd: fd, isTerminal: isTerminal}, in: in} + isDiscard := in == nil + return &In{ + commonStream: commonStream{ + fd: fd, + isTerminal: isTerminal, + isDiscard: isDiscard, + }, + in: in, + } } type appCli struct { in *In out *Out - err io.Writer + err *Out } -func (cli *appCli) In() *In { return cli.in } -func (cli *appCli) Out() *Out { return cli.out } -func (cli *appCli) Err() io.Writer { return cli.err } +func (cli *appCli) In() *In { return cli.in } +func (cli *appCli) Out() *Out { return cli.out } +func (cli *appCli) Err() *Out { return cli.err } func (cli *appCli) Close() error { - err := cli.in.Close() - if err != nil { - return err - } - if out, ok := cli.out.out.(io.Closer); ok { - err = out.Close() - if err != nil { - return err - } - } - - if errout, ok := cli.err.(io.Closer); ok { - err = errout.Close() - if err != nil { - return err - } - } - return nil + return errors.Join( + cli.in.Close(), + cli.out.Close(), + cli.err.Close(), + ) } // NewBasicStreams creates streams with given in, out and err streams. // Give decorate functions to extend functionality. func NewBasicStreams(in io.ReadCloser, out io.Writer, err io.Writer, fns ...StreamsModifierFn) Streams { - if in == nil { - in = io.NopCloser(strings.NewReader("")) - } streams := &appCli{ in: NewIn(in), out: NewOut(out), - err: err, + err: NewOut(err), } for _, fn := range fns { fn(streams) @@ -190,7 +203,6 @@ func NewBasicStreams(in io.ReadCloser, out io.Writer, err io.Writer, fns ...Stre // MaskedStdStreams sets a cli in, out and err streams with the standard streams and with masking of sensitive data. func MaskedStdStreams(mask *SensitiveMask) Streams { - // Set terminal emulation based on platform as required. stdin, stdout, stderr := StdInOutErr() return NewBasicStreams(stdin, stdout, stderr, WithSensitiveMask(mask)) } @@ -206,11 +218,7 @@ func StdInOutErr() (stdIn io.ReadCloser, stdOut, stdErr io.Writer) { // NoopStreams provides streams like /dev/null. func NoopStreams() Streams { - return NewBasicStreams( - nil, - io.Discard, - io.Discard, - ) + return NewBasicStreams(nil, nil, nil) } // StreamsModifierFn is a decorator function for a stream. @@ -220,6 +228,6 @@ type StreamsModifierFn func(streams *appCli) func WithSensitiveMask(m *SensitiveMask) StreamsModifierFn { return func(streams *appCli) { streams.out.out = NewMaskingWriter(streams.out.out, m) - streams.err = NewMaskingWriter(streams.err, m) + streams.err.out = NewMaskingWriter(streams.err.out, m) } } diff --git a/internal/launchr/tools.go b/internal/launchr/tools.go index 5b87720..5f527bc 100644 --- a/internal/launchr/tools.go +++ b/internal/launchr/tools.go @@ -2,7 +2,9 @@ package launchr import ( + "crypto/rand" "errors" + "fmt" "os" "reflect" "sort" @@ -147,3 +149,17 @@ Loop: return commands } + +// GetRandomString generates a random alphanumeric string of the given length. +func GetRandomString(length int) string { + const charset = "abcdefghijklmnopqrstuvwxyz0123456789" + b := make([]byte, length) + _, err := rand.Read(b) + if err != nil { + panic(fmt.Errorf("failed to generate random name: %w", err)) + } + for i := range b { + b[i] = charset[int(b[i])%len(charset)] + } + return string(b) +} diff --git a/internal/launchr/types.go b/internal/launchr/types.go index 01530e7..446703f 100644 --- a/internal/launchr/types.go +++ b/internal/launchr/types.go @@ -174,7 +174,7 @@ func (t Template) WriteFile(name string) error { return err } -// GeneratePlugin is an interface to generate supporting files before build. +// GeneratePlugin is an interface to generate arbitrarily required files before build. type GeneratePlugin interface { Plugin // Generate is a function called when application is generating code and assets for the build. diff --git a/pkg/action/action.go b/pkg/action/action.go index 7f65579..928a5dd 100644 --- a/pkg/action/action.go +++ b/pkg/action/action.go @@ -157,7 +157,8 @@ func (a *Action) syncToDisk() (err error) { if err != nil { return } - fsys, err := fs.Sub(a.fs.fs, a.Dir()) + // We use subpath if there are multiple directories in the FS. + fsys, err := fs.Sub(a.fs.fs, filepath.Dir(a.Filepath())) if err != nil { return } @@ -177,7 +178,14 @@ func (a *Action) Filepath() string { } // Dir returns an action file directory. -func (a *Action) Dir() string { return filepath.Dir(a.Filepath()) } +func (a *Action) Dir() string { + // Sync to disk virtual actions so the data is available in run. + if err := a.syncToDisk(); err != nil { + launchr.Log().Error("failed to sync plugin action to disc", "action", a.ID, "err", err) + return "" + } + return filepath.Dir(a.Filepath()) +} // Runtime returns environment to run the action. func (a *Action) Runtime() Runtime { return a.runtime } diff --git a/pkg/action/action_test.go b/pkg/action/action_test.go index dfa8379..53997b8 100644 --- a/pkg/action/action_test.go +++ b/pkg/action/action_test.go @@ -29,11 +29,13 @@ func Test_Action(t *testing.T) { require.NoError(err) require.NotEmpty(actions) act := actions[0] + // Override the real path to skip [Action.syncToDisc]. + act.fs.real = "/fstest/" // Test dir - assert.Equal(filepath.Dir(act.fpath), act.Dir()) + assert.Equal(act.fs.real+filepath.Dir(act.fpath), act.Dir()) act.fpath = "test/file/path/action.yaml" - assert.Equal("test/file/path", act.Dir()) + assert.Equal(act.fs.real+"test/file/path", act.Dir()) // Test arguments and options. inputArgs := InputParams{"arg1": "arg1", "arg2": "arg2", "arg-1": "arg-1", "arg_12": "arg_12_enum1"} diff --git a/pkg/action/manager.go b/pkg/action/manager.go index 07b489f..26a94b5 100644 --- a/pkg/action/manager.go +++ b/pkg/action/manager.go @@ -10,6 +10,7 @@ import ( "time" "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/driver" ) // DiscoverActionsFn defines a function to discover actions. @@ -396,16 +397,35 @@ func (m *runManagerMap) RunInfoByID(id string) (RunInfo, bool) { } // WithDefaultRuntime adds a default [Runtime] for an action. -func WithDefaultRuntime(_ Manager, a *Action) { - if a.Runtime() != nil { - return +func WithDefaultRuntime(cfg launchr.Config) DecorateWithFn { + type configContainer struct { + DefaultRuntime string `yaml:"default_runtime"` + } + var rtConfig configContainer + err := cfg.Get("runtime.container", &rtConfig) + if err != nil { + launchr.Term().Warning().Printfln("configuration file field %q is malformed", "container") } - def, _ := a.Raw() - switch def.Runtime.Type { - case runtimeTypeContainer: - a.SetRuntime(NewContainerRuntimeDocker()) - case runtimeTypeShell: - a.SetRuntime(NewShellRuntime()) + return func(_ Manager, a *Action) { + if a.Runtime() != nil { + return + } + def, _ := a.Raw() + switch def.Runtime.Type { + case runtimeTypeContainer: + var rt ContainerRuntime + switch driver.Type(rtConfig.DefaultRuntime) { + case driver.Kubernetes: + rt = NewContainerRuntimeKubernetes() + case driver.Docker: + fallthrough + default: + rt = NewContainerRuntimeDocker() + } + a.SetRuntime(rt) + case runtimeTypeShell: + a.SetRuntime(NewShellRuntime()) + } } } diff --git a/pkg/action/runtime.container.go b/pkg/action/runtime.container.go index 1b5fa35..ece2c20 100644 --- a/pkg/action/runtime.container.go +++ b/pkg/action/runtime.container.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "io" "os" osuser "os/user" "path/filepath" @@ -23,7 +22,8 @@ const ( containerActionMount = "/action" // Environment specific flags. - containerFlagUseVolumeWD = "use-volume-wd" + containerFlagRemote = "remote-runtime" + containerFlagCopyBack = "remote-copy-back" containerFlagRemoveImage = "remove-image" containerFlagNoCache = "no-cache" containerFlagEntrypoint = "entrypoint" @@ -37,6 +37,8 @@ type runtimeContainer struct { rtype driver.Type // logWith contains context arguments for a structured logger. logWith []any + // isRemoteRuntime checks if a container is run remotely. + isRemoteRuntime bool // Container related functionality extenders // @todo migrate to events/hooks for loose coupling. @@ -45,12 +47,14 @@ type runtimeContainer struct { nameprv ContainerNameProvider // Runtime flags - useVolWD bool // Deprecated: with no replacement. + isSetRemote bool + copyBack bool removeImg bool noCache bool entrypoint string entrypointSet bool exec bool + volumeFlags string } // ContainerNameProvider provides an ability to generate a random container name @@ -61,13 +65,13 @@ type ContainerNameProvider struct { // Get generates a new container name func (p ContainerNameProvider) Get(name string) string { - var rpl = strings.NewReplacer("-", "_", ":", "_", ".", "_") + var rpl = strings.NewReplacer("_", "-", ":", "-", ".", "-") suffix := "" if p.RandomSuffix { - suffix = "_" + driver.GetRandomName(0) + suffix = "_" + launchr.GetRandomString(4) } - return p.Prefix + rpl.Replace(name) + suffix + return rpl.Replace(p.Prefix + name + suffix) } // NewContainerRuntimeDocker creates a new action Docker runtime. @@ -95,12 +99,18 @@ func (c *runtimeContainer) Clone() Runtime { func (c *runtimeContainer) FlagsDefinition() ParametersList { return ParametersList{ &DefParameter{ - Name: containerFlagUseVolumeWD, - Title: "Use volume as a WD", - Description: "Copy the working directory to a container volume and not bind local paths. Usually used with remote environments.", + Name: containerFlagRemote, + Title: "Remote runtime", + Description: "Forces the container runtime to be used as remote. Copies the working directory to a container volume. Local binds are not used.", Type: jsonschema.Boolean, Default: false, }, + &DefParameter{ + Name: containerFlagCopyBack, + Title: "Remote copy back", + Description: "Copies the working directory back from the container. Works only if the runtime is remote.", + Type: jsonschema.Boolean, + }, &DefParameter{ Name: containerFlagRemoveImage, Title: "Remove Image", @@ -118,14 +128,14 @@ func (c *runtimeContainer) FlagsDefinition() ParametersList { &DefParameter{ Name: containerFlagEntrypoint, Title: "Image Entrypoint", - Description: "Overwrite the default ENTRYPOINT of the image", + Description: `Overwrite the default ENTRYPOINT of the image. Example: --entrypoint "/bin/sh"`, Type: jsonschema.String, Default: "", }, &DefParameter{ Name: containerFlagExec, Title: "Exec command", - Description: "Overwrite CMD definition of the container", + Description: "Overwrite the command of the action. Argument and options are not validated, sets container CMD directly. Example usage: --exec -- ls -lah", Type: jsonschema.Boolean, Default: false, }, @@ -133,8 +143,12 @@ func (c *runtimeContainer) FlagsDefinition() ParametersList { } func (c *runtimeContainer) UseFlags(flags InputParams) error { - if v, ok := flags[containerFlagUseVolumeWD]; ok { - c.useVolWD = v.(bool) + if v, ok := flags[containerFlagRemote]; ok { + c.isSetRemote = v.(bool) + } + + if v, ok := flags[containerFlagCopyBack]; ok { + c.copyBack = v.(bool) } if r, ok := flags[containerFlagRemoveImage]; ok { @@ -169,12 +183,36 @@ func (c *runtimeContainer) AddImageBuildResolver(r ImageBuildResolver) { func (c *runtimeContainer) SetImageBuildCacheResolver(s *ImageBuildCacheResolver) { c.imgccres = s } func (c *runtimeContainer) SetContainerNameProvider(p ContainerNameProvider) { c.nameprv = p } -func (c *runtimeContainer) Init(_ context.Context, _ *Action) (err error) { - c.logWith = nil +func (c *runtimeContainer) Init(ctx context.Context, _ *Action) (err error) { + c.logWith = []any{"run_env", c.rtype} + // Create the client. if c.crt == nil { c.crt, err = driver.New(c.rtype) + if err != nil { + return err + } } - return err + // Check if the environment is remote. + info, err := c.crt.Info(ctx) + if err != nil { + return err + } + c.isRemoteRuntime = info.Remote + + // Set mount flag for SELinux. + if !c.isRemote() && c.isSELinuxEnabled(ctx) { + // Check SELinux settings to allow reading the FS inside a container. + // Use the lowercase z flag to allow concurrent actions access to the FS. + c.volumeFlags += ":z" + launchr.Term().Warning().Printfln( + "SELinux is detected. The volumes will be mounted with the %q flags, which will relabel your files.\n"+ + "This process may take time or potentially break existing permissions.", + c.volumeFlags, + ) + c.log().Warn("using selinux flags", "flags", c.volumeFlags) + } + + return nil } func (c *runtimeContainer) log(attrs ...any) *launchr.Slog { @@ -187,48 +225,28 @@ func (c *runtimeContainer) log(attrs ...any) *launchr.Slog { func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { ctx, cancelFn := context.WithCancel(ctx) defer cancelFn() + + // Prepare runtime variables. streams := a.Input().Streams() runDef := a.RuntimeDef() if runDef.Container == nil { return errors.New("action container configuration is not set, use different runtime") } - log := c.log("run_env", c.rtype, "action_id", a.ID, "image", runDef.Container.Image, "command", runDef.Container.Command) + log := c.log("action_id", a.ID) log.Debug("starting execution of the action") + + // Generate a container name. name := c.nameprv.Get(a.ID) existing := c.crt.ContainerList(ctx, driver.ContainerListOptions{SearchName: name}) if len(existing) > 0 { return fmt.Errorf("the action %q can't start, the container name is in use, please, try again", a.ID) } - var autoRemove = true - if c.useVolWD { - // Do not remove the volume until we copy the data back. - autoRemove = false - } - - // Add entrypoint command option. - var entrypoint []string - if c.entrypointSet { - entrypoint = []string{c.entrypoint} - } - - // Create container. - runConfig := &driver.ContainerCreateOptions{ - ContainerName: name, - ExtraHosts: runDef.Container.ExtraHosts, - AutoRemove: autoRemove, - OpenStdin: true, - StdinOnce: true, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: streams.In().IsTerminal(), - Env: runDef.Container.Env, - User: getCurrentUser(), - Entrypoint: entrypoint, - } + // Create a container. + runConfig := c.createContainerDef(a, name) + log = c.log("image", runConfig.Image, "command", runConfig.Command, "entrypoint", runConfig.Entrypoint) log.Debug("creating a container for an action") - cid, err := c.containerCreate(ctx, a, runConfig) + cid, err := c.containerCreate(ctx, a, &runConfig) if err != nil { return fmt.Errorf("failed to create a container: %w", err) } @@ -236,28 +254,41 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { return errors.New("error on creating a container") } - log = c.log("container_id", cid) - log.Debug("successfully created a container for an action") - // Copy working dirs to the container. - if c.useVolWD { - // @todo test somehow. - launchr.Term().Info().Printfln(`Flag "--%s" is set. Copying the working directory inside the container.`, containerFlagUseVolumeWD) - err = c.copyDirToContainer(ctx, cid, a.WorkDir(), containerHostMount) - if err != nil { - return fmt.Errorf("failed to copy host directory to the container: %w", err) + // Remove the container after finish. + defer func() { + log.Debug("remove container after run") + errRm := c.crt.ContainerRemove(ctx, cid) + if errRm != nil { + log.Error("error on cleaning the running environment", "error", errRm) + } else { + log.Debug("container was successfully removed") } - err = c.copyDirToContainer(ctx, cid, a.Dir(), containerActionMount) - if err != nil { - return fmt.Errorf("failed to copy action directory to the container: %w", err) + }() + + // Remove the used image if it was specified. + defer func() { + if !c.removeImg { + return } - } + log.Debug("removing container image after run") + errImg := c.imageRemove(ctx, a) + if errImg != nil { + log.Error("failed to remove image", "error", errImg) + } else { + log.Debug("image was successfully removed") + } + }() - // Check if TTY was requested, but not supported. - if ttyErr := streams.In().CheckTty(runConfig.AttachStdin, runConfig.Tty); ttyErr != nil { - return ttyErr + log = c.log("container_id", cid) + log.Debug("successfully created a container for an action") + + // Copy working dirs to the container. + err = c.copyAllToContainer(ctx, cid, a) + if err != nil { + return err } - if !runConfig.Tty { + if !runConfig.Streams.TTY { log.Debug("watching container signals") sigc := launchr.NotifySignals() go launchr.HandleSignals(ctx, sigc, func(_ os.Signal, sig string) error { @@ -266,51 +297,32 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { defer launchr.StopCatchSignals(sigc) } - // Attach streams to the terminal. - log.Debug("attaching container streams") - cio, errCh, err := c.attachContainer(ctx, streams, cid, runConfig) - if err != nil { - return fmt.Errorf("failed to attach to the container: %w", err) - } - defer func() { - _ = cio.Close() - }() - log.Debug("watching run status of container") - statusCh := c.containerWait(ctx, cid, runConfig) - // Start the container log.Debug("starting container") - if err = c.crt.ContainerStart(ctx, cid, driver.ContainerStartOptions{}); err != nil { - log.Debug("failed starting the container") - cancelFn() - <-errCh - if runConfig.AutoRemove { - <-statusCh - } + statusCh, cio, err := c.crt.ContainerStart(ctx, cid, runConfig) + if err != nil { + log.Error("failed to start the container", "err", err) return err } - // Resize TTY on window resize. - if runConfig.Tty { - log.Debug("watching TTY resize") - if err = driver.MonitorTtySize(ctx, c.crt, streams, cid, false); err != nil { - log.Error("error monitoring tty size", "error", err) + // Stream container io and watch tty resize. + go func() { + if cio == nil { + return } - } - - log.Debug("waiting execution of the container") - if errCh != nil { - if err = <-errCh; err != nil { - if _, ok := err.(driver.EscapeError); ok { - // The user entered the detach escape sequence. - return nil - } - - log.Debug("error hijack", "error", err) - return err + defer cio.Close() + if runConfig.Streams.TTY { + launchr.Log().Debug("watching TTY resize") + cio.TtyMonitor.Start(ctx, streams) } - } + errStream := cio.Stream(ctx, streams) + if errStream != nil { + launchr.Log().Error("error on streaming container io. The container may still run, waiting for it to finish", "error", err) + } + }() + // Wait for the execution result code. + log.Debug("waiting execution of the container") status := <-statusCh // @todo maybe we should note that SIG was sent to the container. Code 130 is sent on Ctlr+C. log.Info("action finished with the exit code", "exit_code", status) @@ -319,35 +331,12 @@ func (c *runtimeContainer) Execute(ctx context.Context, a *Action) (err error) { } // Copy back the result from the volume. - // @todo it's a bad implementation considering consequential runs, need to find a better way to sync with remote. - if c.useVolWD { - path := a.WorkDir() - launchr.Term().Info().Printfln(`Flag "--%s" is set. Copying back the result of the action run.`, containerFlagUseVolumeWD) - err = c.copyFromContainer(ctx, cid, containerHostMount, filepath.Dir(path), filepath.Base(path)+"/result") - defer func() { - err = c.crt.ContainerRemove(ctx, cid, driver.ContainerRemoveOptions{}) - if err != nil { - log.Error("error on cleaning the running environment", "error", err) - } - }() - if err != nil { - return err - } + errCp := c.copyAllFromContainer(ctx, cid, a) + if err == nil { + // If the run was successful, return a copy error to show that the result is not available. + err = errCp } - defer func() { - if !c.removeImg { - return - } - log.Debug("removing container image after run") - errImg := c.imageRemove(ctx, a) - if errImg != nil { - log.Error("failed to remove image", "error", errImg) - } else { - log.Debug("image was successfully removed") - } - }() - return err } @@ -366,14 +355,16 @@ func getCurrentUser() string { } func (c *runtimeContainer) Close() error { + if c.crt == nil { + return nil + } return c.crt.Close() } func (c *runtimeContainer) imageRemove(ctx context.Context, a *Action) error { if crt, ok := c.crt.(driver.ContainerImageBuilder); ok { _, err := crt.ImageRemove(ctx, a.RuntimeDef().Container.Image, driver.ImageRemoveOptions{ - Force: true, - PruneChildren: false, + Force: true, }) return err } @@ -453,7 +444,7 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { launchr.Term().Printfln("Image %q doesn't exist locally, pulling from the registry...", image) log.Info("image doesn't exist locally, pulling from the registry") // Output docker status only in Debug. - err = driver.DockerDisplayJSONMessages(status.Progress, streams) + err = status.Progress.Stream(streams.Out()) if err != nil { launchr.Term().Error().Println("Error occurred while pulling the image %q", image) log.Error("error while pulling the image", "error", err) @@ -468,7 +459,7 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { launchr.Term().Printfln("Image %q doesn't exist locally, building...", image) log.Info("image doesn't exist locally, building the image") // Output docker status only in Debug. - err = driver.DockerDisplayJSONMessages(status.Progress, streams) + err = status.Progress.Stream(streams.Out()) if err != nil { launchr.Term().Error().Println("Error occurred while building the image %q", image) log.Error("error while building the image", "error", err) @@ -478,79 +469,112 @@ func (c *runtimeContainer) imageEnsure(ctx context.Context, a *Action) error { return err } -func (c *runtimeContainer) containerCreate(ctx context.Context, a *Action, opts *driver.ContainerCreateOptions) (string, error) { +func (c *runtimeContainer) containerCreate(ctx context.Context, a *Action, createOpts *driver.ContainerDefinition) (string, error) { var err error - // Sync to disk virtual actions so the data is available in run. - if err = a.syncToDisk(); err != nil { + if err = c.imageEnsure(ctx, a); err != nil { return "", err } - if err = c.imageEnsure(ctx, a); err != nil { + + cid, err := c.crt.ContainerCreate(ctx, *createOpts) + if err != nil { return "", err } + return cid, nil +} + +func (c *runtimeContainer) createContainerDef(a *Action, cname string) driver.ContainerDefinition { // Create a container runDef := a.RuntimeDef() + streams := a.Input().Streams() - // Override Cmd with exec command. + // Override an entrypoint if it was set in flags. + var entrypoint []string + if c.entrypointSet { + entrypoint = []string{c.entrypoint} + } + + // Override Command with exec command. + cmd := runDef.Container.Command if c.exec { - runDef.Container.Command = a.Input().ArgsPositional() + cmd = a.Input().ArgsPositional() } - createOpts := driver.ContainerCreateOptions{ - ContainerName: opts.ContainerName, + createOpts := driver.ContainerDefinition{ + ContainerName: cname, Image: runDef.Container.Image, - Cmd: runDef.Container.Command, + Command: cmd, WorkingDir: containerHostMount, - NetworkMode: driver.NetworkModeHost, - ExtraHosts: opts.ExtraHosts, - AutoRemove: opts.AutoRemove, - OpenStdin: opts.OpenStdin, - StdinOnce: opts.StdinOnce, - AttachStdin: opts.AttachStdin, - AttachStdout: opts.AttachStdout, - AttachStderr: opts.AttachStderr, - Tty: opts.Tty, - Env: opts.Env, - User: opts.User, - Entrypoint: opts.Entrypoint, - } - - if c.useVolWD { + ExtraHosts: runDef.Container.ExtraHosts, + Env: runDef.Container.Env, + User: getCurrentUser(), + Entrypoint: entrypoint, + Streams: driver.ContainerStreamsOptions{ + Stdin: !streams.In().IsDiscard(), + Stdout: !streams.Out().IsDiscard(), + Stderr: !streams.Err().IsDiscard(), + TTY: streams.In().IsTerminal(), + }, + } + + if c.isRemote() { // Use anonymous volumes to be removed after finish. - createOpts.Volumes = map[string]struct{}{ - containerHostMount: {}, - containerActionMount: {}, - } + createOpts.Volumes = containerAnonymousVolumes( + containerHostMount, + containerActionMount, + ) } else { - flags := "" - // Check SELinux settings to allow reading the FS inside a container. - if c.isSELinuxEnabled(ctx) { - // Use the lowercase z flag to allow concurrent actions access to the FS. - flags += ":z" - launchr.Term().Warning().Printfln( - "SELinux is detected. The volumes will be mounted with the %q flags, which will relabel your files.\n"+ - "This process may take time or potentially break existing permissions.", - flags, - ) - c.log().Warn("using selinux flags", "flags", flags) - } createOpts.Binds = []string{ - launchr.MustAbs(a.WorkDir()) + ":" + containerHostMount + flags, - launchr.MustAbs(a.Dir()) + ":" + containerActionMount + flags, + launchr.MustAbs(a.WorkDir()) + ":" + containerHostMount + c.volumeFlags, + launchr.MustAbs(a.Dir()) + ":" + containerActionMount + c.volumeFlags, } } - cid, err := c.crt.ContainerCreate(ctx, createOpts) - if err != nil { - return "", err + return createOpts +} + +func containerAnonymousVolumes(paths ...string) []driver.ContainerVolume { + volumes := make([]driver.ContainerVolume, len(paths)) + for i := 0; i < len(paths); i++ { + volumes[i] = driver.ContainerVolume{MountPath: paths[i]} } + return volumes +} - return cid, nil +func (c *runtimeContainer) copyAllToContainer(ctx context.Context, cid string, a *Action) (err error) { + if !c.isRemote() { + return nil + } + // @todo test somehow. + launchr.Term().Info().Printfln(`Running in the remote environment. Copying the working directory and action directory inside the container.`) + // Copy dir to a container to have the same owner in the destination directory. + // Copying only the content of the dir will not override the parent dir ownership. + err = c.copyToContainer(ctx, cid, a.WorkDir(), filepath.Dir(containerHostMount), filepath.Base(containerHostMount)) + if err != nil { + return fmt.Errorf("failed to copy host directory to the container: %w", err) + } + err = c.copyToContainer(ctx, cid, a.Dir(), filepath.Dir(containerActionMount), filepath.Base(containerActionMount)) + if err != nil { + return fmt.Errorf("failed to copy action directory to the container: %w", err) + } + return nil } -// copyDirToContainer copies dir content to a container. -// Helpful to have the same owner in the destination directory. -func (c *runtimeContainer) copyDirToContainer(ctx context.Context, cid, srcPath, dstPath string) error { - return c.copyToContainer(ctx, cid, srcPath, filepath.Dir(dstPath), filepath.Base(dstPath)) +func (c *runtimeContainer) copyAllFromContainer(ctx context.Context, cid string, a *Action) (err error) { + if !c.isRemote() || !c.copyBack { + return nil + } + // @todo it's a bad implementation considering consequential runs, need to find a better way to sync with remote. + // We may need to consider creating a session (by user command) before action run. + // After the session is created, create a named volume in docker or a pod+volume in k8s. + // All consequential actions will reuse the volume. + // After the session ends (by user command), copy all back. + // Delete volume or pod after finish. + // Or do not copy at all, define a session with prepare script that will prepare the environment. + src := containerHostMount + dst := a.WorkDir() + + launchr.Term().Info().Printfln(`Running in the remote environment and "--%s" is set. Copying back the result of the action run.`, containerFlagCopyBack) + return c.copyFromContainer(ctx, cid, src, filepath.Dir(dst), filepath.Base(dst)) } // copyToContainer copies dir/file to a container. Directory will be copied as a subdirectory. @@ -606,55 +630,6 @@ func (c *runtimeContainer) copyFromContainer(ctx context.Context, cid, srcPath, return archive.Untar(content, dstPath, &archive.TarOptions{SrcInfo: srcInfo}) } -func (c *runtimeContainer) containerWait(ctx context.Context, cid string, opts *driver.ContainerCreateOptions) <-chan int { - log := c.log() - // Wait for the container to stop or catch error. - waitCond := driver.WaitConditionNextExit - if opts.AutoRemove { - waitCond = driver.WaitConditionRemoved - } - resCh, errCh := c.crt.ContainerWait(ctx, cid, driver.ContainerWaitOptions{Condition: waitCond}) - statusC := make(chan int) - go func() { - select { - case err := <-errCh: - log.Error("error waiting for container", "error", err) - statusC <- 125 - case res := <-resCh: - if res.Error != nil { - log.Error("error in container run", "error", res.Error) - statusC <- 125 - } else { - log.Debug("received run status code", "exit_code", res.StatusCode) - statusC <- res.StatusCode - } - case <-ctx.Done(): - log.Debug("stop waiting for container on context finish") - statusC <- 125 - } - }() - - return statusC -} - -func (c *runtimeContainer) attachContainer(ctx context.Context, streams launchr.Streams, cid string, opts *driver.ContainerCreateOptions) (io.Closer, <-chan error, error) { - cio, errAttach := c.crt.ContainerAttach(ctx, cid, driver.ContainerAttachOptions{ - Stream: true, - Stdin: opts.AttachStdin, - Stdout: opts.AttachStdout, - Stderr: opts.AttachStderr, - }) - if errAttach != nil { - return nil, nil, errAttach - } - - errCh := make(chan error, 1) - go func() { - errCh <- driver.ContainerIOStream(ctx, streams, cio, opts) - }() - return cio, errCh, nil -} - func (c *runtimeContainer) isSELinuxEnabled(ctx context.Context) bool { // First, we check if it's enabled at the OS level, then if it's enabled in the container runner. // If the feature is not enabled in the runner environment, @@ -662,3 +637,7 @@ func (c *runtimeContainer) isSELinuxEnabled(ctx context.Context) bool { d, ok := c.crt.(driver.ContainerRunnerSELinux) return ok && launchr.IsSELinuxEnabled() && d.IsSELinuxSupported(ctx) } + +func (c *runtimeContainer) isRemote() bool { + return c.isRemoteRuntime || c.isSetRemote +} diff --git a/pkg/action/runtime.container_test.go b/pkg/action/runtime.container_test.go index 323b005..256ee47 100644 --- a/pkg/action/runtime.container_test.go +++ b/pkg/action/runtime.container_test.go @@ -1,7 +1,6 @@ package action import ( - "bytes" "context" "errors" "fmt" @@ -10,10 +9,7 @@ import ( "strings" "testing" "testing/fstest" - "time" - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/pkg/stdcopy" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" @@ -25,6 +21,14 @@ import ( const containerNamePrefix = "test_prefix_" +const ( + mockFnImageEnsure = "ImageEnsure" + mockFnContainerCreate = "ContainerCreate" + mockFnContainerStart = "ContainerStart" + mockFnContainerRemove = "ContainerRemove" + mockFnImageRemove = "ImageRemove" +) + type eqImageOpts struct { x driver.ImageOptions } @@ -37,15 +41,14 @@ func (e eqImageOpts) String() string { return fmt.Sprintf("is equal to %v (%T)", e.x, e.x) } -var cfgImgRes = LaunchrConfigImageBuildResolver{launchrCfg()} +var cfgImgRes = LaunchrConfigImageBuildResolver{dummyCfg()} -func launchrCfg() launchr.Config { +func dummyCfg() launchr.Config { cfgRoot := fstest.MapFS{"config.yaml": &fstest.MapFile{Data: []byte(cfgYaml)}} return launchr.ConfigFromFS(cfgRoot) } -func prepareContainerTestSuite(t *testing.T) (*assert.Assertions, *gomock.Controller, *containermock.MockContainerRuntime, *runtimeContainer) { - assert := assert.New(t) +func prepareContainerTestSuite(t *testing.T) (*gomock.Controller, *containermock.MockContainerRuntime, *runtimeContainer) { ctrl := gomock.NewController(t) d := containermock.NewMockContainerRuntime(ctrl) d.EXPECT().Close() @@ -53,13 +56,14 @@ func prepareContainerTestSuite(t *testing.T) (*assert.Assertions, *gomock.Contro r.AddImageBuildResolver(cfgImgRes) r.SetContainerNameProvider(ContainerNameProvider{Prefix: containerNamePrefix}) - return assert, ctrl, d, r + return ctrl, d, r } func testContainerAction(cdef *DefRuntimeContainer) *Action { if cdef == nil { cdef = &DefRuntimeContainer{ - Image: "myimage", + Image: "myimage", + Command: []string{"my", "cmd"}, ExtraHosts: []string{ "my:host1", "my:host2", @@ -85,18 +89,6 @@ func testContainerAction(cdef *DefRuntimeContainer) *Action { return a } -func testContainerIO() *driver.ContainerInOut { - outBytes := []byte("0test stdOut") - // Set row header for moby.stdCopy proper parsing of combined streams. - outBytes[0] = byte(stdcopy.Stdout) - return &driver.ContainerInOut{ - In: &fakeWriter{ - Buffer: bytes.NewBuffer([]byte{}), - }, - Out: bytes.NewBuffer(outBytes), - } -} - func Test_ContainerExec_imageEnsure(t *testing.T) { t.Parallel() @@ -120,7 +112,10 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { } var r *driver.ImageStatusResponse if s != -1 { - r = &driver.ImageStatusResponse{Status: s, Progress: p} + r = &driver.ImageStatusResponse{ + Status: s, + Progress: &driver.ImageProgressStream{ReadCloser: p}, + } } return []any{r, err} } @@ -137,7 +132,7 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { "image pulled", &DefRuntimeContainer{Image: "pull"}, nil, - imgFn(driver.ImagePull, `{"stream":"Successfully pulled image\n"}`, nil), + imgFn(driver.ImagePull, `"Successfully pulled image"`, nil), }, { "image pulled error", @@ -145,15 +140,15 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { nil, imgFn( driver.ImagePull, - `{"errorDetail":{"code":1,"message":"fake pull error"},"error":"fake pull error"}`, - &jsonmessage.JSONError{Code: 1, Message: "fake pull error"}, + "fake pull error", + fmt.Errorf("fake pull error"), ), }, { "image build local", aconf, actLoc.ImageBuildInfo(aconf.Image), - imgFn(driver.ImageBuild, `{"stream":"Successfully built image \"local\"\n"}`, nil), + imgFn(driver.ImageBuild, `Successfully built image "local"`, nil), }, { "image build local error", @@ -161,15 +156,15 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { actLoc.ImageBuildInfo(aconf.Image), imgFn( driver.ImageBuild, - `{"errorDetail":{"code":1,"message":"fake build error"},"error":"fake build error"}`, - &jsonmessage.JSONError{Code: 1, Message: "fake build error"}, + "fake build error", + fmt.Errorf("fake build error"), ), }, { "image build config", &DefRuntimeContainer{Image: "build:config"}, cfgImgRes.ImageBuildInfo("build:config"), - imgFn(driver.ImageBuild, `{"stream":"Successfully built image \"config\"\n"}`, nil), + imgFn(driver.ImageBuild, `Successfully built image "config"`, nil), }, { "container runtime error", @@ -184,7 +179,7 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert, ctrl, d, r := prepareContainerTestSuite(t) + ctrl, d, r := prepareContainerTestSuite(t) defer ctrl.Finish() defer r.Close() ctx := context.Background() @@ -196,7 +191,7 @@ func Test_ContainerExec_imageEnsure(t *testing.T) { ImageEnsure(ctx, eqImageOpts{imgOpts}). Return(tt.ret...) err := r.imageEnsure(ctx, act) - assert.Equal(tt.ret[1], err) + assert.Equal(t, tt.ret[1], err) }) } } @@ -237,7 +232,7 @@ func Test_ContainerExec_imageRemove(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert, ctrl, d, r := prepareContainerTestSuite(t) + ctrl, d, r := prepareContainerTestSuite(t) ctx := context.Background() defer ctrl.Finish() @@ -247,179 +242,118 @@ func Test_ContainerExec_imageRemove(t *testing.T) { act.input = NewInput(act, nil, nil, launchr.NoopStreams()) run := act.RuntimeDef().Container - imgOpts := driver.ImageRemoveOptions{Force: true, PruneChildren: false} + imgOpts := driver.ImageRemoveOptions{Force: true} d.EXPECT(). ImageRemove(ctx, run.Image, gomock.Eq(imgOpts)). Return(tt.ret...) err := r.imageRemove(ctx, act) - assert.Equal(err, tt.ret[1]) + assert.Equal(t, err, tt.ret[1]) }) } } -func Test_ContainerExec_containerCreate(t *testing.T) { +func Test_ContainerExec_createContainerDef(t *testing.T) { t.Parallel() - assert, ctrl, d, r := prepareContainerTestSuite(t) - defer ctrl.Finish() - defer r.Close() - - a := testContainerAction(nil) - run := a.RuntimeDef() - - runCfg := &driver.ContainerCreateOptions{ - ContainerName: "container", - NetworkMode: driver.NetworkModeHost, - ExtraHosts: run.Container.ExtraHosts, - AutoRemove: true, - OpenStdin: true, - StdinOnce: true, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - Env: []string{ - "env1=val1", - "env2=val2", - }, - } - eqCfg := *runCfg - eqCfg.Binds = []string{ - launchr.MustAbs(a.WorkDir()) + ":" + containerHostMount, - launchr.MustAbs(a.Dir()) + ":" + containerActionMount, - } - eqCfg.WorkingDir = containerHostMount - eqCfg.Cmd = run.Container.Command - eqCfg.Image = run.Container.Image - - ctx := context.Background() - - // Normal create. - expCid := "container_id" - d.EXPECT(). - ImageEnsure(ctx, driver.ImageOptions{Name: run.Container.Image}). - Return(&driver.ImageStatusResponse{Status: driver.ImageExists}, nil) - d.EXPECT(). - ContainerCreate(ctx, gomock.Eq(eqCfg)). - Return(expCid, nil) - - cid, err := r.containerCreate(ctx, a, runCfg) - require.NoError(t, err) - assert.Equal(expCid, cid) - - // Create with a custom wd - a.def.WD = "../myactiondir" - wd := launchr.MustAbs(a.def.WD) - eqCfg.Binds = []string{ - wd + ":" + containerHostMount, - launchr.MustAbs(a.Dir()) + ":" + containerActionMount, - } - d.EXPECT(). - ImageEnsure(ctx, driver.ImageOptions{Name: run.Container.Image}). - Return(&driver.ImageStatusResponse{Status: driver.ImageExists}, nil) - d.EXPECT(). - ContainerCreate(ctx, gomock.Eq(eqCfg)). - Return(expCid, nil) - - cid, err = r.containerCreate(ctx, a, runCfg) - require.NoError(t, err) - assert.Equal(expCid, cid) - - // Create with anonymous volumes. - r.useVolWD = true - eqCfg.Binds = nil - eqCfg.Volumes = map[string]struct{}{ - containerHostMount: {}, - containerActionMount: {}, + type testCase struct { + name string + newAct func(*runtimeContainer) *Action + exp driver.ContainerDefinition } - d.EXPECT(). - ImageEnsure(ctx, driver.ImageOptions{Name: run.Container.Image}). - Return(&driver.ImageStatusResponse{Status: driver.ImageExists}, nil) - d.EXPECT(). - ContainerCreate(ctx, gomock.Eq(eqCfg)). - Return(expCid, nil) - - cid, err = r.containerCreate(ctx, a, runCfg) - require.NoError(t, err) - assert.Equal(expCid, cid) - - // Image ensure fail. - errImg := fmt.Errorf("error on image ensure") - d.EXPECT(). - ImageEnsure(ctx, driver.ImageOptions{Name: run.Container.Image}). - Return(nil, errImg) - - cid, err = r.containerCreate(ctx, a, runCfg) - assert.Error(err) - assert.Equal("", cid) - - // Container create fail. - expErr := fmt.Errorf("container create error") - d.EXPECT(). - ImageEnsure(ctx, driver.ImageOptions{Name: run.Container.Image}). - Return(&driver.ImageStatusResponse{Status: driver.ImageExists}, nil) - d.EXPECT(). - ContainerCreate(ctx, gomock.Any()). - Return("", expErr) - cid, err = r.containerCreate(ctx, a, runCfg) - assert.Error(err) - assert.Equal("", cid) -} -func Test_ContainerExec_containerWait(t *testing.T) { - t.Parallel() - assert, ctrl, d, r := prepareContainerTestSuite(t) - defer ctrl.Finish() - defer r.Close() - - type testCase struct { - name string - chanFn func(resCh chan driver.ContainerWaitResponse, errCh chan error) - waitCond driver.WaitCondition - expStatus int + baseRes := driver.ContainerDefinition{ + Image: "myimage", + WorkingDir: containerHostMount, + ExtraHosts: []string{ + "my:host1", + "my:host2", + }, + Env: []string{ + "env1=var1", + "env2=var2", + }, + User: getCurrentUser(), } + defaultCmd := []string{"my", "cmd"} + var defaultEntrypoint []string + actionDir := launchr.MustAbs("my/action/test") tts := []testCase{ { - "condition removed", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0} + "local binds, default working dir, stdin, tty", + func(_ *runtimeContainer) *Action { + a := testContainerAction(nil) + streams := launchr.NewBasicStreams(io.NopCloser(strings.NewReader("")), io.Discard, io.Discard) + streams.In().SetIsTerminal(true) + input := NewInput(a, nil, nil, streams) + input.SetValidated(true) + _ = a.SetInput(input) + return a }, - driver.WaitConditionRemoved, - 0, - }, - { - "condition next exit", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0} + driver.ContainerDefinition{ + ContainerName: launchr.GetRandomString(4), + Command: defaultCmd, + Entrypoint: defaultEntrypoint, + Binds: []string{ + launchr.MustAbs("./") + ":" + containerHostMount, + actionDir + ":" + containerActionMount, + }, + Streams: driver.ContainerStreamsOptions{ + Stdin: true, + Stdout: true, + Stderr: true, + TTY: true, + }, }, - driver.WaitConditionNextExit, - 0, }, { - "return exit code", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 2} + "local binds, different working dir, no stdin, no tty", + func(_ *runtimeContainer) *Action { + a := testContainerAction(nil) + input := NewInput(a, nil, nil, launchr.NewBasicStreams(nil, io.Discard, io.Discard)) + input.SetValidated(true) + _ = a.SetInput(input) + a.def.WD = "../myactiondir" + return a }, - driver.WaitConditionRemoved, - 2, - }, - { - "fail on container run", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0, Error: errors.New("fail run")} + driver.ContainerDefinition{ + ContainerName: launchr.GetRandomString(4), + Command: defaultCmd, + Entrypoint: defaultEntrypoint, + Binds: []string{ + launchr.MustAbs("../myactiondir") + ":" + containerHostMount, + actionDir + ":" + containerActionMount, + }, + Streams: driver.ContainerStreamsOptions{ + Stdout: true, + Stderr: true, + }, }, - driver.WaitConditionRemoved, - 125, }, { - "fail on wait", - func(_ chan driver.ContainerWaitResponse, errCh chan error) { - errCh <- errors.New("fail wait") + "remote volumes, no attach, remote runtime, override entrypoint, override cmd", + func(r *runtimeContainer) *Action { + a := testContainerAction(nil) + args, _ := ArgsPosToNamed(a, []string{"arg1", "arg2"}) + input := NewInput(a, args, nil, launchr.NoopStreams()) + input.SetValidated(true) + _ = a.SetInput(input) + r.isRemoteRuntime = true + r.entrypointSet = true + r.entrypoint = "/my/entrypoint" + r.exec = true + return a + }, + driver.ContainerDefinition{ + ContainerName: launchr.GetRandomString(4), + Command: []string{"arg1", "arg2"}, + Entrypoint: []string{"/my/entrypoint"}, + Volumes: containerAnonymousVolumes( + containerHostMount, + containerActionMount, + ), }, - driver.WaitConditionRemoved, - 125, }, } @@ -427,86 +361,31 @@ func Test_ContainerExec_containerWait(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - // Set timeout for broken channel cases. - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - - // Prepare channels with buffer for non-blocking. - cid := "" - resCh, errCh := make(chan driver.ContainerWaitResponse, 1), make(chan error, 1) - tt.chanFn(resCh, errCh) - d.EXPECT(). - ContainerWait(ctx, cid, driver.ContainerWaitOptions{Condition: tt.waitCond}). - Return(resCh, errCh) + ctrl, _, r := prepareContainerTestSuite(t) + defer ctrl.Finish() + defer r.Close() - // Test waiting and status. - autoRemove := false - if tt.waitCond == driver.WaitConditionRemoved { - autoRemove = true - } - runCfg := &driver.ContainerCreateOptions{AutoRemove: autoRemove} - ch := r.containerWait(ctx, cid, runCfg) - assert.Equal(tt.expStatus, <-ch) + a := tt.newAct(r) + a.SetRuntime(r) + cname := tt.exp.ContainerName + tt.exp.Image = baseRes.Image + tt.exp.WorkingDir = baseRes.WorkingDir + tt.exp.ExtraHosts = baseRes.ExtraHosts + tt.exp.Env = baseRes.Env + tt.exp.User = baseRes.User + runCfg := r.createContainerDef(a, cname) + assert.Equal(t, tt.exp, runCfg) }) } } -type fakeWriter struct { - *bytes.Buffer -} - -func (f *fakeWriter) Close() error { - f.Buffer.Reset() - return nil -} - -func Test_ContainerExec_containerAttach(t *testing.T) { - t.Parallel() - assert, ctrl, d, r := prepareContainerTestSuite(t) - streams := launchr.NoopStreams() - defer ctrl.Finish() - defer r.Close() - - ctx := context.Background() - cid := "" - cio := testContainerIO() - opts := &driver.ContainerCreateOptions{ - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: true, - } - attOpts := driver.ContainerAttachOptions{ - Stream: true, - Stdin: opts.AttachStdin, - Stdout: opts.AttachStdout, - Stderr: opts.AttachStderr, - } - d.EXPECT(). - ContainerAttach(ctx, cid, attOpts). - Return(cio, nil) - acio, errCh, err := r.attachContainer(ctx, streams, cid, opts) - assert.Equal(acio, cio) - require.NoError(t, err) - require.NoError(t, <-errCh) - _ = acio.Close() - - expErr := errors.New("fail to attach") - d.EXPECT(). - ContainerAttach(ctx, cid, attOpts). - Return(nil, expErr) - acio, errCh, err = r.attachContainer(ctx, streams, cid, opts) - assert.Equal(nil, acio) - assert.Equal(expErr, err) - assert.Nil(errCh) -} - type mockCallInfo struct { fn string minTimes int maxTimes int args []any ret []any + prepFn func(*mockCallInfo) } func Test_ContainerExec(t *testing.T) { @@ -516,176 +395,151 @@ func Test_ContainerExec(t *testing.T) { act := testContainerAction(nil) runConf := act.RuntimeDef().Container imgBuild := &driver.ImageStatusResponse{Status: driver.ImageExists} - cio := testContainerIO() nprv := ContainerNameProvider{Prefix: containerNamePrefix} type testCase struct { name string - prepFn func(resCh chan driver.ContainerWaitResponse, errCh chan error) steps []mockCallInfo expErr error } - opts := driver.ContainerCreateOptions{ + opts := driver.ContainerDefinition{ ContainerName: nprv.Get(act.ID), - Cmd: runConf.Command, + Command: runConf.Command, Image: runConf.Image, - NetworkMode: driver.NetworkModeHost, ExtraHosts: runConf.ExtraHosts, Binds: []string{ launchr.MustAbs(act.WorkDir()) + ":" + containerHostMount, launchr.MustAbs(act.Dir()) + ":" + containerActionMount, }, - WorkingDir: containerHostMount, - AutoRemove: true, - OpenStdin: true, - StdinOnce: true, - AttachStdin: true, - AttachStdout: true, - AttachStderr: true, - Tty: false, - Env: runConf.Env, - User: getCurrentUser(), - } - attOpts := driver.ContainerAttachOptions{ - Stream: true, - Stdin: opts.AttachStdin, - Stdout: opts.AttachStdout, - Stderr: opts.AttachStderr, + WorkingDir: containerHostMount, + Env: runConf.Env, + User: getCurrentUser(), } errImgEns := errors.New("image ensure error") errCreate := errors.New("container create error") - errAttach := errors.New("attach error") errStart := errors.New("start error") errExecError := launchr.NewExitError(2, fmt.Sprintf("action %q finished with exit code 2", act.ID)) + const ( + stepImageEnsure = iota + stepContainerCreate + stepContainerStart + stepContainerRemove + ) + successSteps := []mockCallInfo{ - { - "ImageEnsure", + stepImageEnsure: { + mockFnImageEnsure, 1, 1, []any{eqImageOpts{driver.ImageOptions{Name: runConf.Image}}}, []any{imgBuild, nil}, + nil, }, - { - "ContainerCreate", + stepContainerCreate: { + mockFnContainerCreate, 1, 1, []any{opts}, []any{cid, nil}, + nil, }, - { - "ContainerAttach", - 1, 1, - []any{cid, attOpts}, - []any{cio, nil}, - }, - { - "ContainerWait", + stepContainerStart: { + mockFnContainerStart, 1, 1, - []any{cid, driver.ContainerWaitOptions{Condition: driver.WaitConditionRemoved}}, - []any{}, + []any{cid, gomock.Any()}, + []any{nil, nil, nil}, + func(mock *mockCallInfo) { + resCh := make(chan int, 1) + mock.ret[0] = resCh + resCh <- 0 + }, }, - { - "ContainerStart", + stepContainerRemove: { + mockFnContainerRemove, 1, 1, - []any{cid, driver.ContainerStartOptions{}}, + []any{cid}, []any{nil}, + nil, }, } tts := []testCase{ { "successful run", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0} - }, successSteps, nil, }, { "image ensure error", - nil, []mockCallInfo{ { - "ImageEnsure", + mockFnImageEnsure, 1, 1, []any{gomock.Any()}, []any{imgBuild, errImgEns}, + nil, }, }, errImgEns, }, { "container create error", - nil, append( - slices.Clone(successSteps[0:1]), + slices.Clone(successSteps[stepImageEnsure:stepContainerCreate]), mockCallInfo{ - "ContainerCreate", + mockFnContainerCreate, 1, 1, []any{gomock.Any()}, []any{"", errCreate}, + nil, }), errCreate, }, { "container create error - empty container id", - nil, append( - slices.Clone(successSteps[0:1]), + slices.Clone(successSteps[stepImageEnsure:stepContainerCreate]), mockCallInfo{ - "ContainerCreate", + mockFnContainerCreate, 1, 1, []any{gomock.Any()}, []any{"", nil}, + nil, }), errTestAny{}, }, - { - "error on container attach", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0} - }, - append( - slices.Clone(successSteps[0:2]), - mockCallInfo{ - "ContainerAttach", - 1, 1, - []any{cid, gomock.Any()}, - []any{cio, errAttach}, - }, - ), - errAttach, - }, { "error start container", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 0} - }, append( - slices.Clone(successSteps[0:4]), + slices.Clone(successSteps[stepImageEnsure:stepContainerStart]), mockCallInfo{ - "ContainerStart", + mockFnContainerStart, 1, 1, []any{cid, gomock.Any()}, - []any{errStart}, + []any{nil, nil, errStart}, + nil, }, + successSteps[stepContainerRemove], ), errStart, }, { "container return error", - func(resCh chan driver.ContainerWaitResponse, _ chan error) { - resCh <- driver.ContainerWaitResponse{StatusCode: 2} - }, append( - slices.Clone(successSteps[0:4]), + slices.Clone(successSteps[stepImageEnsure:stepContainerStart]), mockCallInfo{ - "ContainerStart", + mockFnContainerStart, 1, 1, []any{cid, gomock.Any()}, - []any{nil}, + []any{gomock.Any(), nil, nil}, + func(mock *mockCallInfo) { + resCh := make(chan int, 1) + mock.ret[0] = resCh + resCh <- 2 + }, }, + successSteps[stepContainerRemove], ), errExecError, }, @@ -695,8 +549,7 @@ func Test_ContainerExec(t *testing.T) { tt := tt t.Run(tt.name, func(t *testing.T) { t.Parallel() - resCh, errCh := make(chan driver.ContainerWaitResponse, 1), make(chan error, 1) - _, ctrl, d, r := prepareContainerTestSuite(t) + ctrl, d, r := prepareContainerTestSuite(t) a := act.Clone() input := NewInput(a, nil, nil, launchr.NoopStreams()) input.SetValidated(true) @@ -707,14 +560,11 @@ func Test_ContainerExec(t *testing.T) { var prev *gomock.Call d.EXPECT().ContainerList(gomock.Any(), gomock.Any()).Return(nil) // @todo test different container names for _, step := range tt.steps { - if step.fn == "ContainerWait" { //nolint:goconst - step.ret = []any{resCh, errCh} + if step.prepFn != nil { + step.prepFn(&step) } prev = callContainerDriverMockFn(d, step, prev) } - if tt.prepFn != nil { - tt.prepFn(resCh, errCh) - } ctx := context.Background() err = r.Execute(ctx, a) assertIsSameError(t, tt.expErr, err) @@ -725,30 +575,28 @@ func Test_ContainerExec(t *testing.T) { func callContainerDriverMockFn(d *containermock.MockContainerRuntime, step mockCallInfo, prev *gomock.Call) *gomock.Call { var call *gomock.Call switch step.fn { - case "ImageEnsure": + case mockFnImageEnsure: call = d.EXPECT(). ImageEnsure(gomock.Any(), step.args[0]). Return(step.ret...) - case "ContainerCreate": + case mockFnContainerCreate: call = d.EXPECT(). ContainerCreate(gomock.Any(), step.args[0]). Return(step.ret...) - case "ContainerAttach": - call = d.EXPECT(). - ContainerAttach(gomock.Any(), step.args[0], step.args[1]). - Return(step.ret...) - case "ContainerWait": - call = d.EXPECT(). - ContainerWait(gomock.Any(), step.args[0], step.args[1]). - Return(step.ret...) - case "ContainerStart": + case mockFnContainerStart: call = d.EXPECT(). ContainerStart(gomock.Any(), step.args[0], step.args[1]). Return(step.ret...) - case "ImageRemove": + case mockFnImageRemove: call = d.EXPECT(). ImageRemove(gomock.Any(), step.args[0], step.args[1]). Return(step.ret...) + case mockFnContainerRemove: + call = d.EXPECT(). + ContainerRemove(gomock.Any(), step.args[0]). + Return(step.ret...) + default: + panic("unknown function: " + step.fn) } if step.minTimes > 1 { call.MinTimes(step.minTimes) @@ -783,7 +631,6 @@ func (f fsmy) MapFS() fstest.MapFS { func Test_ConfigImageBuildInfo(t *testing.T) { t.Parallel() - assert := assert.New(t) type testCase struct { name string @@ -803,7 +650,7 @@ func Test_ConfigImageBuildInfo(t *testing.T) { t.Parallel() cfg := launchr.ConfigFromFS(tt.fs.MapFS()) cfgImgRes := LaunchrConfigImageBuildResolver{cfg} - assert.NotNil(cfg) + assert.NotNil(t, cfg) if img := cfgImgRes.ImageBuildInfo("my/image:version"); (img == nil) == tt.expImg { t.Errorf("expected image to find in config directory") } diff --git a/pkg/action/test_utils.go b/pkg/action/test_utils.go index 08f0218..9b39f30 100644 --- a/pkg/action/test_utils.go +++ b/pkg/action/test_utils.go @@ -7,8 +7,9 @@ import ( "testing" "testing/fstest" - "github.com/docker/docker/pkg/namesgenerator" "github.com/stretchr/testify/assert" + + "github.com/launchrctl/launchr/internal/launchr" ) type genPathType int @@ -22,13 +23,13 @@ const ( func genActionPath(d int, pathType genPathType) string { elems := make([]string, 0, d+3) for i := 0; i < d; i++ { - elems = append(elems, namesgenerator.GetRandomName(0)) + elems = append(elems, launchr.GetRandomString(4)) } switch pathType { case genPathTypeValid: - elems = append(elems, actionsDirname, namesgenerator.GetRandomName(0)) + elems = append(elems, actionsDirname, launchr.GetRandomString(4)) case genPathTypeGHActions: - elems = append(elems, ".github", actionsDirname, namesgenerator.GetRandomName(0)) + elems = append(elems, ".github", actionsDirname, launchr.GetRandomString(4)) case genPathTypeArbitrary: // Do nothing. default: diff --git a/pkg/archive/tar.go b/pkg/archive/tar.go index 25590c7..aa6fcbf 100644 --- a/pkg/archive/tar.go +++ b/pkg/archive/tar.go @@ -9,11 +9,12 @@ import ( "path/filepath" "slices" - "github.com/docker/docker/pkg/archive" + "github.com/moby/go-archive" + "github.com/moby/go-archive/compression" ) // Compression is the state represents if compressed or not. -type Compression archive.Compression +type Compression compression.Compression // Compressions types. const ( @@ -69,7 +70,7 @@ func Tar(src CopyInfo, dst CopyInfo, opts *TarOptions) (io.ReadCloser, error) { tarOpts := archive.TarResourceRebaseOpts(sourceBase, srcInfo.RebaseName) tarOpts.ExcludePatterns = opts.ExcludePatterns maps.Insert(tarOpts.RebaseNames, maps.All(opts.RebaseNames)) - tarOpts.Compression = archive.Compression(opts.Compression) + tarOpts.Compression = compression.Compression(opts.Compression) slices.AppendSeq(tarOpts.IncludeFiles, slices.Values(opts.IncludeFiles)) r, err := archive.TarWithOptions(sourceDir, tarOpts) @@ -119,7 +120,7 @@ func Untar(content io.ReadCloser, dstPath string, opts *TarOptions) error { return err } - dstDir, copyArchive, err := archive.PrepareArchiveCopy(content, srcInfo, dstInfo) + dstDir, copyArchive, err := archive.PrepareArchiveCopy(preArchive, srcInfo, dstInfo) if err != nil { return err } diff --git a/pkg/driver/docker.go b/pkg/driver/docker.go index e181046..b9d6c7a 100644 --- a/pkg/driver/docker.go +++ b/pkg/driver/docker.go @@ -3,39 +3,55 @@ package driver import ( "context" "errors" + "fmt" "io" - "github.com/launchrctl/launchr/pkg/archive" - dockertypes "github.com/docker/docker/api/types" "github.com/docker/docker/api/types/container" "github.com/docker/docker/api/types/filters" "github.com/docker/docker/api/types/image" "github.com/docker/docker/client" "github.com/docker/docker/errdefs" + "github.com/docker/docker/pkg/jsonmessage" + "github.com/docker/docker/pkg/stdcopy" + + "github.com/launchrctl/launchr/internal/launchr" + "github.com/launchrctl/launchr/pkg/archive" ) -type dockerDriver struct { - cli client.APIClient +const dockerNetworkModeHost = "host" + +// ContainerWaitResponse stores response given by wait result. +type dockerWaitResponse struct { + StatusCode int + Error error +} + +type dockerRuntime struct { + cli client.APIClient + info SystemInfo } -// NewDockerDriver creates a docker driver. -func NewDockerDriver() (ContainerRunner, error) { +// NewDockerRuntime creates a docker runtime. +func NewDockerRuntime() (ContainerRunner, error) { // @todo it doesn't work with Colima or with non-default context. c, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) if err != nil { return nil, err } - return &dockerDriver{cli: c}, nil + return &dockerRuntime{cli: c}, nil } -func (d *dockerDriver) Info(ctx context.Context) (SystemInfo, error) { +func (d *dockerRuntime) Info(ctx context.Context) (SystemInfo, error) { + if d.info.ID != "" { + return d.info, nil + } info, err := d.cli.Info(ctx) if err != nil { return SystemInfo{}, err } - return SystemInfo{ + d.info = SystemInfo{ ID: info.ID, Name: info.Name, ServerVersion: info.ServerVersion, @@ -47,10 +63,13 @@ func (d *dockerDriver) Info(ctx context.Context) (SystemInfo, error) { NCPU: info.NCPU, MemTotal: info.MemTotal, SecurityOptions: info.SecurityOptions, - }, nil + // @todo consider remote environments where we can't directly bind local dirs. + Remote: false, + } + return d.info, nil } -func (d *dockerDriver) IsSELinuxSupported(ctx context.Context) bool { +func (d *dockerRuntime) IsSELinuxSupported(ctx context.Context) bool { info, errInfo := d.cli.Info(ctx) if errInfo != nil { return false @@ -63,7 +82,7 @@ func (d *dockerDriver) IsSELinuxSupported(ctx context.Context) bool { return false } -func (d *dockerDriver) ContainerList(ctx context.Context, opts ContainerListOptions) []ContainerListResult { +func (d *dockerRuntime) ContainerList(ctx context.Context, opts ContainerListOptions) []ContainerListResult { f := filters.NewArgs() f.Add("name", opts.SearchName) l, err := d.cli.ContainerList(ctx, container.ListOptions{ @@ -84,9 +103,9 @@ func (d *dockerDriver) ContainerList(ctx context.Context, opts ContainerListOpti return lp } -func (d *dockerDriver) ImageEnsure(ctx context.Context, imgOpts ImageOptions) (*ImageStatusResponse, error) { +func (d *dockerRuntime) ImageEnsure(ctx context.Context, imgOpts ImageOptions) (*ImageStatusResponse, error) { // Check if the image already exists. - insp, _, err := d.cli.ImageInspectWithRaw(ctx, imgOpts.Name) + insp, err := d.cli.ImageInspect(ctx, imgOpts.Name) if err != nil { if !errdefs.IsNotFound(err) { return nil, err @@ -116,18 +135,32 @@ func (d *dockerDriver) ImageEnsure(ctx context.Context, imgOpts ImageOptions) (* if errBuild != nil { return nil, errBuild } - return &ImageStatusResponse{Status: ImageBuild, Progress: resp.Body}, nil + return &ImageStatusResponse{ + Status: ImageBuild, + Progress: &ImageProgressStream{ + ReadCloser: resp.Body, + streamer: dockerDisplayJSONMessages, + }, + }, nil } // Pull the specified image. reader, err := d.cli.ImagePull(ctx, imgOpts.Name, image.PullOptions{}) if err != nil { return &ImageStatusResponse{Status: ImageUnexpectedError}, err } - return &ImageStatusResponse{Status: ImagePull, Progress: reader}, nil + return &ImageStatusResponse{ + Status: ImagePull, + Progress: &ImageProgressStream{ + ReadCloser: reader, + streamer: dockerDisplayJSONMessages, + }, + }, nil } -func (d *dockerDriver) ImageRemove(ctx context.Context, img string, options ImageRemoveOptions) (*ImageRemoveResponse, error) { - _, err := d.cli.ImageRemove(ctx, img, image.RemoveOptions(options)) +func (d *dockerRuntime) ImageRemove(ctx context.Context, img string, options ImageRemoveOptions) (*ImageRemoveResponse, error) { + _, err := d.cli.ImageRemove(ctx, img, image.RemoveOptions{ + Force: options.Force, + }) if err != nil { return nil, err @@ -136,44 +169,52 @@ func (d *dockerDriver) ImageRemove(ctx context.Context, img string, options Imag return &ImageRemoveResponse{Status: ImageRemoved}, nil } -func (d *dockerDriver) CopyToContainer(ctx context.Context, cid string, path string, content io.Reader, opts CopyToContainerOptions) error { +func (d *dockerRuntime) CopyToContainer(ctx context.Context, cid string, path string, content io.Reader, opts CopyToContainerOptions) error { return d.cli.CopyToContainer(ctx, cid, path, content, container.CopyToContainerOptions(opts)) } -func (d *dockerDriver) CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, ContainerPathStat, error) { +func (d *dockerRuntime) CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, ContainerPathStat, error) { r, stat, err := d.cli.CopyFromContainer(ctx, cid, srcPath) return r, ContainerPathStat(stat), err } -func (d *dockerDriver) ContainerStatPath(ctx context.Context, cid string, path string) (ContainerPathStat, error) { +func (d *dockerRuntime) ContainerStatPath(ctx context.Context, cid string, path string) (ContainerPathStat, error) { res, err := d.cli.ContainerStatPath(ctx, cid, path) return ContainerPathStat(res), err } -func (d *dockerDriver) ContainerCreate(ctx context.Context, opts ContainerCreateOptions) (string, error) { +func (d *dockerRuntime) ContainerCreate(ctx context.Context, opts ContainerDefinition) (string, error) { hostCfg := &container.HostConfig{ - AutoRemove: opts.AutoRemove, ExtraHosts: opts.ExtraHosts, - NetworkMode: container.NetworkMode(opts.NetworkMode), + NetworkMode: dockerNetworkModeHost, Binds: opts.Binds, } + // Prepare volumes. + volumes := make(map[string]struct{}, len(opts.Volumes)) + for i := 0; i < len(opts.Volumes); i++ { + volume := "" + if opts.Volumes[i].Name != "" { + volume += opts.Volumes[i].Name + ":" + } + volume += opts.Volumes[i].MountPath + } + resp, err := d.cli.ContainerCreate( ctx, &container.Config{ Hostname: opts.Hostname, Image: opts.Image, - Cmd: opts.Cmd, + Cmd: opts.Command, WorkingDir: opts.WorkingDir, - OpenStdin: opts.OpenStdin, - StdinOnce: opts.StdinOnce, - AttachStdin: opts.AttachStdin, - AttachStdout: opts.AttachStdout, - AttachStderr: opts.AttachStderr, - Tty: opts.Tty, + OpenStdin: opts.Streams.Stdin, + AttachStdin: opts.Streams.Stdin, + AttachStdout: opts.Streams.Stdout, + AttachStderr: opts.Streams.Stderr, + Tty: opts.Streams.TTY, Env: opts.Env, User: opts.User, - Volumes: opts.Volumes, + Volumes: volumes, Entrypoint: opts.Entrypoint, }, hostCfg, @@ -186,21 +227,41 @@ func (d *dockerDriver) ContainerCreate(ctx context.Context, opts ContainerCreate return resp.ID, nil } -func (d *dockerDriver) ContainerStart(ctx context.Context, cid string, _ ContainerStartOptions) error { - return d.cli.ContainerStart(ctx, cid, container.StartOptions{}) +func (d *dockerRuntime) ContainerStart(ctx context.Context, cid string, runConfig ContainerDefinition) (<-chan int, *ContainerInOut, error) { + // Attach streams to the terminal. + launchr.Log().Debug("attaching container streams") + cio, err := d.ContainerAttach(ctx, cid, runConfig.Streams) + if err != nil { + return nil, nil, fmt.Errorf("failed to attach to the container: %w", err) + } + defer func() { + if err != nil { + _ = cio.Close() + } + }() + + launchr.Log().Debug("watching run status of container") + statusCh := d.doContainerWait(ctx, cid) + + err = d.cli.ContainerStart(ctx, cid, container.StartOptions{}) + if err != nil { + return nil, nil, err + } + + return statusCh, cio, nil } -func (d *dockerDriver) ContainerWait(ctx context.Context, cid string, opts ContainerWaitOptions) (<-chan ContainerWaitResponse, <-chan error) { - statusCh, errCh := d.cli.ContainerWait(ctx, cid, container.WaitCondition(opts.Condition)) +func (d *dockerRuntime) containerWait(ctx context.Context, cid string) (<-chan dockerWaitResponse, <-chan error) { + statusCh, errCh := d.cli.ContainerWait(ctx, cid, container.WaitConditionNextExit) - wrappedStCh := make(chan ContainerWaitResponse) + wrappedStCh := make(chan dockerWaitResponse) go func() { st := <-statusCh var err error if st.Error != nil { err = errors.New(st.Error.Message) } - wrappedStCh <- ContainerWaitResponse{ + wrappedStCh <- dockerWaitResponse{ StatusCode: int(st.StatusCode), Error: err, } @@ -209,36 +270,123 @@ func (d *dockerDriver) ContainerWait(ctx context.Context, cid string, opts Conta return wrappedStCh, errCh } -func (d *dockerDriver) ContainerAttach(ctx context.Context, containerID string, options ContainerAttachOptions) (*ContainerInOut, error) { - resp, err := d.cli.ContainerAttach(ctx, containerID, container.AttachOptions(options)) +func (d *dockerRuntime) ContainerAttach(ctx context.Context, cid string, options ContainerStreamsOptions) (*ContainerInOut, error) { + resp, err := d.cli.ContainerAttach(ctx, cid, container.AttachOptions{ + Stream: true, + Stdin: options.Stdin, + Stdout: options.Stdout, + Stderr: options.Stderr, + }) if err != nil { return nil, err } - return &ContainerInOut{In: resp.Conn, Out: resp.Reader}, nil + cio := &ContainerInOut{ + In: resp.Conn, + Out: resp.Reader, + Opts: options, + } + + // Resize TTY on window resize. + if options.TTY { + cio.TtyMonitor = NewTtySizeMonitor(func(ctx context.Context, ropts terminalSize) error { + return d.cli.ContainerResize(ctx, cid, container.ResizeOptions(ropts)) + }) + } + + // Only need to demultiplex if we have multiplexed output + if !options.TTY && resp.Reader != nil && options.Stdout { + // Create pipes for stdout and stderr + stdoutReader, stdoutWriter := io.Pipe() + stderrReader, stderrWriter := io.Pipe() + + // Start demultiplexing in a goroutine + go func() { + defer stdoutWriter.Close() + defer stderrWriter.Close() + + // Demultiplex the output stream into our pipes + _, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, resp.Reader) + if err != nil { + // If an error occurs during demultiplexing, write it to the stderr pipe + launchr.Log().Error("\"error demultiplexing container output", "error", err) + } + }() + cio.Out = stdoutReader + cio.Err = stderrReader + + // Return the ContainerInOut with demultiplexed streams + return cio, nil + } + + // If no demultiplexing is needed, return the raw connection + return cio, nil + } -func (d *dockerDriver) ContainerStop(ctx context.Context, cid string) error { - return d.cli.ContainerStop(ctx, cid, container.StopOptions{}) +func (d *dockerRuntime) ContainerStop(ctx context.Context, cid string, opts ContainerStopOptions) error { + var timeout *int + if opts.Timeout != nil { + t := int(opts.Timeout.Seconds()) + timeout = &t + } + return d.cli.ContainerStop(ctx, cid, container.StopOptions{ + Timeout: timeout, + }) } -func (d *dockerDriver) ContainerRemove(ctx context.Context, cid string, _ ContainerRemoveOptions) error { - return d.cli.ContainerRemove(ctx, cid, container.RemoveOptions{}) +func (d *dockerRuntime) ContainerRemove(ctx context.Context, cid string) error { + return d.cli.ContainerRemove(ctx, cid, container.RemoveOptions{ + RemoveVolumes: true, + }) } -func (d *dockerDriver) ContainerKill(ctx context.Context, containerID, signal string) error { +func (d *dockerRuntime) ContainerKill(ctx context.Context, containerID, signal string) error { return d.cli.ContainerKill(ctx, containerID, signal) } -func (d *dockerDriver) ContainerResize(ctx context.Context, cid string, opts ResizeOptions) error { - return d.cli.ContainerResize(ctx, cid, container.ResizeOptions(opts)) +// Close closes docker cli connection. +func (d *dockerRuntime) Close() error { + return d.cli.Close() } -func (d *dockerDriver) ContainerExecResize(ctx context.Context, cid string, opts ResizeOptions) error { - return d.cli.ContainerExecResize(ctx, cid, container.ResizeOptions(opts)) +func (d *dockerRuntime) doContainerWait(ctx context.Context, cid string) <-chan int { + // Wait for the container to stop or catch error. + resCh, errCh := d.containerWait(ctx, cid) + statusC := make(chan int) + go func() { + select { + case err := <-errCh: + launchr.Log().Error("error waiting for container", "error", err) + statusC <- 125 + case res := <-resCh: + if res.Error != nil { + launchr.Log().Error("error in container run", "error", res.Error) + statusC <- 125 + } else { + launchr.Log().Debug("received run status code", "exit_code", res.StatusCode) + statusC <- res.StatusCode + } + case <-ctx.Done(): + launchr.Log().Debug("stop waiting for container on context finish") + statusC <- 125 + } + }() + + return statusC } -// Close closes docker cli connection. -func (d *dockerDriver) Close() error { - return d.cli.Close() +// dockerDisplayJSONMessages prints docker json output to streams. +func dockerDisplayJSONMessages(in io.Reader, out *launchr.Out) error { + err := jsonmessage.DisplayJSONMessagesStream(in, out, out.FD(), out.IsTerminal(), nil) + if err != nil { + if jerr, ok := err.(*jsonmessage.JSONError); ok { + // If no error code is set, default to 1 + if jerr.Code == 0 { + jerr.Code = 1 + } + return jerr + } + } + return err } diff --git a/pkg/driver/factory.go b/pkg/driver/factory.go index e41abe4..0eb13d3 100644 --- a/pkg/driver/factory.go +++ b/pkg/driver/factory.go @@ -4,7 +4,7 @@ import ( "fmt" ) -// Type defines implemented driver types. +// Type defines implemented container runtime types. type Type string // Available container runtime types. @@ -13,11 +13,13 @@ const ( Kubernetes Type = "kubernetes" // Kubernetes runtime. ) -// New creates a new driver based on a type. +// New creates a new container runtime based on a type. func New(t Type) (ContainerRunner, error) { switch t { case Docker: - return NewDockerDriver() + return NewDockerRuntime() + case Kubernetes: + return NewKubernetesRuntime() default: panic(fmt.Sprintf("container runtime %q is not implemented", t)) } diff --git a/pkg/driver/hijack.go b/pkg/driver/hijack.go deleted file mode 100644 index 31ce57d..0000000 --- a/pkg/driver/hijack.go +++ /dev/null @@ -1,250 +0,0 @@ -package driver - -import ( - "context" - "fmt" - "io" - "runtime" - "sync" - - "github.com/docker/docker/api/types" - "github.com/docker/docker/pkg/ioutils" - "github.com/docker/docker/pkg/stdcopy" - "github.com/moby/term" - - "github.com/launchrctl/launchr/internal/launchr" -) - -// The default escape key sequence: ctrl-p, ctrl-q -var defaultEscapeKeys = []byte{16, 17} - -// EscapeError is an error thrown when escape sequence is input. -type EscapeError = term.EscapeError - -// ContainerInOut stores container driver in/out streams. -type ContainerInOut struct { - In io.WriteCloser - Out io.Reader -} - -// Close closes the hijacked connection and reader. -func (h *ContainerInOut) Close() error { - return h.In.Close() -} - -// CloseWrite closes a readWriter for writing. -func (h *ContainerInOut) CloseWrite() error { - if conn, ok := h.In.(types.CloseWriter); ok { - return conn.CloseWrite() - } - return nil -} - -// Streamer is an interface for streaming in given in/out/err. -type Streamer interface { - Stream(ctx context.Context) error - Close() error -} - -// ContainerIOStream streams in/out/err to given streams. -// @todo consider license reference. -func ContainerIOStream(ctx context.Context, streams launchr.Streams, cio *ContainerInOut, config *ContainerCreateOptions) error { - var ( - out, cerr io.Writer - in io.ReadCloser - ) - if config.AttachStdin { - in = streams.In() - } - if config.AttachStdout { - out = streams.Out() - } - if config.AttachStderr { - if config.Tty { - cerr = streams.Out() - } else { - cerr = streams.Err() - } - } - - streamer := hijackedIOStreamer{ - streams: streams, - inputStream: in, - outputStream: out, - errorStream: cerr, - io: cio, - tty: config.Tty, - } - - errHijack := streamer.stream(ctx) - return errHijack -} - -type hijackedIOStreamer struct { - streams launchr.Streams - inputStream io.ReadCloser - outputStream io.Writer - errorStream io.Writer - - io *ContainerInOut - - tty bool - detachKeys string -} - -func (h *hijackedIOStreamer) stream(ctx context.Context) error { - restoreInput, err := h.setupInput() - if err != nil { - return fmt.Errorf("unable to setup input stream: %s", err) - } - - defer restoreInput() - - outputDone := h.beginOutputStream(restoreInput) - inputDone, detached := h.beginInputStream(restoreInput) - - select { - case err := <-outputDone: - return err - case <-inputDone: - // Input stream has closed. - if h.outputStream != nil || h.errorStream != nil { - // Wait for output to complete streaming. - select { - case err := <-outputDone: - return err - case <-ctx.Done(): - return ctx.Err() - } - } - return nil - case err := <-detached: - // Got a detach key sequence. - return err - case <-ctx.Done(): - return ctx.Err() - } -} - -func (h *hijackedIOStreamer) setupInput() (restore func(), err error) { - if h.inputStream == nil || !h.tty { - // No need to setup input TTY. - // The restore func is a nop. - return func() {}, nil - } - - if err := setRawTerminal(h.streams); err != nil { - return nil, fmt.Errorf("unable to set io streams as raw terminal: %s", err) - } - - // Use sync.Once so we may call restore multiple times but ensure we - // only restore the terminal once. - var restoreOnce sync.Once - restore = func() { - restoreOnce.Do(func() { - _ = restoreTerminal(h.streams, h.inputStream) - }) - } - - // Wrap the input to detect detach escape sequence. - // Use default escape keys if an invalid sequence is given. - escapeKeys := defaultEscapeKeys - if h.detachKeys != "" { - customEscapeKeys, err := term.ToBytes(h.detachKeys) - if err != nil { - launchr.Log().Warn("invalid detach escape keys, using default", "error", err) - } else { - escapeKeys = customEscapeKeys - } - } - - h.inputStream = ioutils.NewReadCloserWrapper(term.NewEscapeProxy(h.inputStream, escapeKeys), h.inputStream.Close) - - return restore, nil -} - -func (h *hijackedIOStreamer) beginOutputStream(restoreInput func()) <-chan error { - if h.outputStream == nil && h.errorStream == nil { - // There is no need to copy output. - return nil - } - - outputDone := make(chan error) - go func() { - var err error - - // When TTY is ON, use regular copy - if h.outputStream != nil && h.tty { - _, err = io.Copy(h.outputStream, h.io.Out) - // We should restore the terminal as soon as possible - // once the connection ends so any following print - // messages will be in normal type. - restoreInput() - } else { - _, err = stdcopy.StdCopy(h.outputStream, h.errorStream, h.io.Out) - } - - launchr.Log().Debug("[hijack] End of stdout") - - if err != nil { - launchr.Log().Debug("error receive stdout", "error", err) - } - - outputDone <- err - }() - - return outputDone -} - -func (h *hijackedIOStreamer) beginInputStream(restoreInput func()) (doneC <-chan struct{}, detachedC <-chan error) { - inputDone := make(chan struct{}) - detached := make(chan error) - - go func() { - if h.inputStream != nil { - _, err := io.Copy(h.io.In, h.inputStream) - // We should restore the terminal as soon as possible - // once the connection ends so any following print - // messages will be in normal type. - restoreInput() - - launchr.Log().Debug("[hijack] End of stdin") - if _, ok := err.(EscapeError); ok { - detached <- err - return - } - - if err != nil { - // This error will also occur on the receive - // side (from stdout) where it will be - // propagated back to the caller. - launchr.Log().Debug("Error send Stdin", "error", err) - } - } - - if err := h.io.CloseWrite(); err != nil { - launchr.Log().Debug("Couldn't send EOF", "error", err) - } - - close(inputDone) - }() - - return inputDone, detached -} - -func setRawTerminal(streams launchr.Streams) error { - if err := streams.In().SetRawTerminal(); err != nil { - return err - } - return streams.Out().SetRawTerminal() -} - -func restoreTerminal(streams launchr.Streams, in io.Closer) error { - streams.In().RestoreTerminal() - streams.Out().RestoreTerminal() - // See github.com/docker/cli repo for more info. - if in != nil && runtime.GOOS != "darwin" && runtime.GOOS != "windows" { //nolint:goconst - return in.Close() - } - return nil -} diff --git a/pkg/driver/iostream.go b/pkg/driver/iostream.go new file mode 100644 index 0000000..11cc9c8 --- /dev/null +++ b/pkg/driver/iostream.go @@ -0,0 +1,220 @@ +package driver + +import ( + "context" + "errors" + "fmt" + "io" + "runtime" + "sync" + + "github.com/launchrctl/launchr/internal/launchr" +) + +// ContainerInOut stores container in/out streams. +type ContainerInOut struct { + In io.WriteCloser + Out io.Reader + Err io.Reader + + Opts ContainerStreamsOptions + TtyMonitor *TtySizeMonitor +} + +// Close closes the IO and underlying connection. +func (cio *ContainerInOut) Close() error { + return cio.In.Close() +} + +// Stream streams in/out/err to given streams. +func (cio *ContainerInOut) Stream(ctx context.Context, streams launchr.Streams) error { + var ( + out, cerr io.Writer + in io.ReadCloser + ) + if cio.Opts.Stdin { + in = streams.In() + } + if cio.Opts.Stdout { + out = streams.Out() + } + if cio.Opts.Stderr { + if cio.Opts.TTY { + cerr = streams.Out() + } else { + cerr = streams.Err() + } + } + + streamer := ioStreamer{ + streams: streams, + inputStream: in, + outputStream: out, + errorStream: cerr, + io: cio, + tty: cio.Opts.TTY, + } + + return streamer.stream(ctx) +} + +type ioStreamer struct { + streams launchr.Streams + inputStream io.ReadCloser + outputStream io.Writer + errorStream io.Writer + + io *ContainerInOut + tty bool +} + +func (h *ioStreamer) stream(ctx context.Context) error { + restoreInput, err := h.setupInput() + if err != nil { + return fmt.Errorf("unable to setup input stream: %s", err) + } + + defer restoreInput() + + outputDone := h.beginOutputStream(restoreInput) + inputDone := h.beginInputStream(restoreInput) + + select { + case err := <-outputDone: + return err + case <-inputDone: + // Input stream has closed. + if h.outputStream != nil || h.errorStream != nil { + // Wait for output to complete streaming. + select { + case err := <-outputDone: + return err + case <-ctx.Done(): + return ctx.Err() + } + } + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (h *ioStreamer) setupInput() (restore func(), err error) { + if h.inputStream == nil || !h.tty { + // No need to setup input TTY. + // The restore func is a nop. + return func() {}, nil + } + + if err := setRawTerminal(h.streams); err != nil { + return nil, fmt.Errorf("unable to set io streams as raw terminal: %s", err) + } + + // Use sync.Once so we may call restore multiple times but ensure we + // only restore the terminal once. + var restoreOnce sync.Once + restore = func() { + restoreOnce.Do(func() { + _ = restoreTerminal(h.streams, h.inputStream) + }) + } + + return restore, nil +} + +func (h *ioStreamer) beginOutputStream(restoreInput func()) <-chan error { + if h.outputStream == nil && h.errorStream == nil { + // There is no need to copy output. + return nil + } + + outputDone := make(chan error) + go func() { + var err error + + // Copy streams. + errChOut := h.copy(h.outputStream, h.io.Out) + errChErr := h.copy(h.errorStream, h.io.Err) + err = errors.Join( + <-errChErr, + <-errChOut, + ) + // We should restore the terminal as soon as possible + // once the connection ends so any following print + // messages will be in normal type. + restoreInput() + + if err != nil { + launchr.Log().Debug("error receive stdout", "error", err) + } + launchr.Log().Debug("end of stdout/stderr") + + outputDone <- err + }() + + return outputDone +} + +func (h *ioStreamer) copy(dst io.Writer, src io.Reader) <-chan error { + errChan := make(chan error) + go func() { + var err error + if dst != nil && src != nil { + _, err = io.Copy(dst, src) + } + errChan <- err + close(errChan) + }() + return errChan +} + +func (h *ioStreamer) beginInputStream(restoreInput func()) (doneC <-chan struct{}) { + inputDone := make(chan struct{}) + + go func() { + if h.inputStream != nil { + _, err := io.Copy(h.io.In, h.inputStream) + // We should restore the terminal as soon as possible + // once the connection ends so any following print + // messages will be in normal type. + restoreInput() + + launchr.Log().Debug("end of stdin") + + if err != nil { + // This error will also occur on the receive + // side (from stdout) where it will be + // propagated back to the caller. + launchr.Log().Debug("Error send Stdin", "error", err) + } + } + + if conn, ok := h.io.In.(interface{ CloseWrite() error }); ok { + err := conn.CloseWrite() + if err != nil { + launchr.Log().Debug("couldn't send EOF", "error", err) + } + } + + close(inputDone) + }() + + return inputDone +} + +func setRawTerminal(streams launchr.Streams) error { + if err := streams.In().SetRawTerminal(); err != nil { + return err + } + return streams.Out().SetRawTerminal() +} + +func restoreTerminal(streams launchr.Streams, in io.Closer) error { + streams.In().RestoreTerminal() + streams.Out().RestoreTerminal() + // See github.com/docker/cli repo for more info. + if in != nil && runtime.GOOS != "darwin" && runtime.GOOS != "windows" { //nolint:goconst + return in.Close() + } + return nil +} diff --git a/pkg/driver/k8s.utils.go b/pkg/driver/k8s.utils.go new file mode 100644 index 0000000..d9da1b9 --- /dev/null +++ b/pkg/driver/k8s.utils.go @@ -0,0 +1,259 @@ +package driver + +import ( + "fmt" + "io" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/util/httpstream" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "k8s.io/client-go/tools/remotecommand" +) + +func k8sGetConfig() (*restclient.Config, error) { + // Try to use in-cluster config + config, err := restclient.InClusterConfig() + if err != nil { + // Fall back to kubeconfig + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = filepath.Join(os.Getenv("HOME"), ".kube", "config") + } + + config, err = clientcmd.BuildConfigFromFlags("", kubeconfig) + if err != nil { + return nil, fmt.Errorf("failed to get Kubernetes config: %w", err) + } + } + + return config, nil +} + +func k8sCreateExecutor(url *url.URL, config *restclient.Config) (remotecommand.Executor, error) { + executor, err := remotecommand.NewSPDYExecutor(config, "POST", url) + if err != nil { + return nil, err + } + // Fallback executor is default, unless feature flag is explicitly disabled. + if k8sUseWebsocket { + // WebSocketExecutor must be "GET" method as described in RFC 6455 Sec. 4.1 (page 17). + websocketExec, err := remotecommand.NewWebSocketExecutor(config, "GET", url.String()) + if err != nil { + return nil, err + } + executor, err = remotecommand.NewFallbackExecutor(websocketExec, executor, func(err error) bool { + return httpstream.IsUpgradeFailure(err) || httpstream.IsHTTPSProxyError(err) + }) + if err != nil { + return nil, err + } + } + return executor, nil +} + +func k8sCreateContainerID(namespace, podName, containerName string) string { + return namespace + "/" + podName + "/" + containerName +} + +func k8sPodMainContainerID(cid string) string { + namespace, podName, _ := k8sParseContainerID(cid) + return k8sCreateContainerID(namespace, podName, k8sMainPodContainer) +} + +func k8sParseContainerID(cid string) (string, string, string) { + parts := strings.SplitN(cid, "/", 3) + return parts[0], parts[1], parts[2] +} + +func k8sVolumesAndMounts(opts ContainerDefinition) ([]corev1.Volume, []corev1.VolumeMount) { + // Prepare volumes. + containerName := opts.ContainerName + volumes := make([]corev1.Volume, len(opts.Volumes)) + mounts := make([]corev1.VolumeMount, len(opts.Volumes)) + for i := 0; i < len(volumes); i++ { + name := opts.Volumes[i].Name + if name == "" { + name = containerName + "-" + strconv.Itoa(i) + } + mounts[i] = corev1.VolumeMount{ + Name: name, + MountPath: opts.Volumes[i].MountPath, + } + volumes[i] = corev1.Volume{ + Name: name, + VolumeSource: corev1.VolumeSource{ + EmptyDir: &corev1.EmptyDirVolumeSource{}, + }, + } + } + return volumes, mounts +} + +func k8sEnvVars(opts ContainerDefinition) []corev1.EnvVar { + envVars := make([]corev1.EnvVar, len(opts.Env)) + for i := 0; i < len(envVars); i++ { + parts := strings.SplitN(opts.Env[i], "=", 2) + envVars[i] = corev1.EnvVar{Name: parts[0], Value: parts[1]} + } + return envVars +} + +func k8sHostAliases(opts ContainerDefinition) []corev1.HostAlias { + hostAliases := make([]corev1.HostAlias, len(opts.ExtraHosts)) + for i := 0; i < len(hostAliases); i++ { + parts := strings.SplitN(opts.ExtraHosts[i], ":", 2) + hostAliases[i] = corev1.HostAlias{ + IP: parts[1], + Hostnames: parts[0:1], + } + } + return hostAliases +} + +type k8sResizeQueue chan *remotecommand.TerminalSize + +func (q k8sResizeQueue) Next() *remotecommand.TerminalSize { return <-q } + +// There is a strange bug that tar keeps streaming zero bytes after EOF. +// Maybe because the consumer (stdout in k8s streamer) is not handling it. +// The command execution ends because of `pipeReader.Close()` +// and it gives an error because it writes in a closed pipe. +// We prevent it by sending a special error. +type k8sTarPipeReader struct{ *io.PipeReader } + +func (r *k8sTarPipeReader) Close() error { return r.CloseWithError(errK8sStopTarPipeWrite) } + +type k8sStreams struct { + in io.Reader + out io.Writer + err io.Writer + opts ContainerStreamsOptions + tty remotecommand.TerminalSizeQueue +} + +func (s k8sStreams) streamOptions() remotecommand.StreamOptions { + var stdout, stderr io.Writer + var stdin io.Reader + if s.opts.Stdin { + stdin = s.in + } + if s.opts.Stdout { + stdout = s.out + } + // Do not set stderr because both stdout and stderr go over stdout when tty is set. + if s.opts.Stderr && !s.opts.TTY { + stderr = s.err + } + + return remotecommand.StreamOptions{ + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Tty: s.opts.TTY, + + TerminalSizeQueue: s.tty, + } +} + +// parseStatOutput parses the formatted output from the stat command +func parseStatOutput(output string, path string) ContainerPathStat { + // Split the stat output by pipe character + // Format: name|size|mode_hex|mtime_unix|linktarget + const ( + idxName = iota + idxSize + idxMode + idxTime + idxLink + ) + output = strings.TrimSpace(output) + parts := strings.Split(output, "|") + if len(parts) < 5 { + panic(fmt.Sprintf("invalid stat output format: %s, path %s", output, path)) + } + + // Parse size + size, err := strconv.ParseInt(parts[idxSize], 10, 64) + if err != nil { + panic(fmt.Errorf("failed to parse file size: %w", err)) + } + + // Parse mode (hex format from stat -c %f) + modeHex, err := strconv.ParseUint(parts[idxMode], 16, 32) + if err != nil { + panic(fmt.Errorf("failed to parse file mode: %w", err)) + } + mode := fillFileStatFromSys(uint32(modeHex)) //nolint:gosec // G115: overflow should be ok + + // Parse modification time (unix timestamp) + mtimeUnix, err := strconv.ParseInt(parts[idxTime], 10, 64) + if err != nil { + panic(fmt.Errorf("failed to parse modification time: %w", err)) + } + mtime := time.Unix(mtimeUnix, 0) + + // Return the PathStat structure + return ContainerPathStat{ + Name: parts[idxName], + Size: size, + Mode: mode, + Mtime: mtime, + LinkTarget: parts[idxLink], + } +} + +// fillFileStatFromSys parses linux stat output. +// Based on the linux version of [os.fillFileStatFromSys]. +func fillFileStatFromSys(modeHex uint32) os.FileMode { + //nolint // Preserve the same names as in [syscall]. + const ( + S_IFBLK = 0x6000 + S_IFCHR = 0x2000 + S_IFDIR = 0x4000 + S_IFIFO = 0x1000 + S_IFLNK = 0xa000 + S_IFMT = 0xf000 + S_IFREG = 0x8000 + S_IFSOCK = 0xc000 + S_ISGID = 0x400 + S_ISUID = 0x800 + S_ISVTX = 0x200 + ) + + mode := os.FileMode(modeHex) + mode = mode & os.ModePerm + + switch modeHex & S_IFMT { + case S_IFBLK: + mode |= os.ModeDevice + case S_IFCHR: + mode |= os.ModeDevice | os.ModeCharDevice + case S_IFDIR: + mode |= os.ModeDir + case S_IFIFO: + mode |= os.ModeNamedPipe + case S_IFLNK: + mode |= os.ModeSymlink + case S_IFREG: + // nothing to do + case S_IFSOCK: + mode |= os.ModeSocket + } + if mode&S_ISGID != 0 { + mode |= os.ModeSetgid + } + if mode&S_ISUID != 0 { + mode |= os.ModeSetuid + } + if mode&S_ISVTX != 0 { + mode |= os.ModeSticky + } + return mode +} diff --git a/pkg/driver/kubernetes.go b/pkg/driver/kubernetes.go new file mode 100644 index 0000000..e0d764b --- /dev/null +++ b/pkg/driver/kubernetes.go @@ -0,0 +1,527 @@ +package driver + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + restclient "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/client-go/util/exec" + + "github.com/launchrctl/launchr/internal/launchr" +) + +const k8sMainPodContainer = "supervisor" +const k8sUseWebsocket = true +const k8sStatPathScript = ` +FILE="%s" +if [ -e "$FILE" ]; then + # Get file stats + STAT=$(stat -c "%%n|%%s|%%f|%%Y" "$FILE") + # Check if it's a symlink and get target if it is + if [ -L "$FILE" ]; then + TARGET=$(readlink -n "$FILE") + echo "$STAT|$TARGET" + else + echo "$STAT|" + fi + exit 0 +else + echo "File not found: $FILE" >&2 + exit 1 +fi +` +const k8sWaitAttachScript = ` +# Wait for signal USR1 to break loop +signal_received=0 +handle_signal() { + signal_received=1 +} +trap 'handle_signal' USR1 + +# Wait until signal_received becomes 1 +while [ "$signal_received" -eq 0 ]; do + sleep 1 +done + +exec "$@" +` + +var errK8sStopTarPipeWrite = errors.New("k8s: break tar pipe write") + +func init() { + // Override k8s logger. + runtime.ErrorHandlers = []runtime.ErrorHandler{ + k8sLogError, + } +} + +func k8sLogError(_ context.Context, err error, msg string, keysAndValues ...interface{}) { + if err == errK8sStopTarPipeWrite { + return + } + launchr.Log(). + With(keysAndValues...). + Debug("unhandled error in kubernetes runtime", "error", err, "msg", msg) +} + +type k8sRuntime struct { + config *restclient.Config + clientset *kubernetes.Clientset +} + +// NewKubernetesRuntime creates a kubernetes container runtime. +func NewKubernetesRuntime() (ContainerRunner, error) { + // Get Kubernetes config + config, err := k8sGetConfig() + if err != nil { + return nil, fmt.Errorf("failed to get Kubernetes config: %w", err) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create Kubernetes client: %w", err) + } + + return &k8sRuntime{ + config: config, + clientset: clientset, + }, nil +} + +func (k *k8sRuntime) Info(_ context.Context) (SystemInfo, error) { + return SystemInfo{ + // Kubernetes is always a remote environment. + Remote: true, + }, nil +} + +func (k *k8sRuntime) CopyToContainer(ctx context.Context, cid string, path string, content io.Reader, opts CopyToContainerOptions) error { + // Create the command to extract the tar + var cmdArr []string + if opts.CopyUIDGID { + cmdArr = []string{"tar", "-xmf", "-"} + } else { + cmdArr = []string{"tar", "--no-same-permissions", "--no-same-owner", "-xmf", "-"} + } + cmdArr = append(cmdArr, "-C", path) + + // Execute the command in the container, streaming in the tar file + return k.containerExec(ctx, k8sPodMainContainerID(cid), cmdArr, k8sStreams{ + in: content, + opts: ContainerStreamsOptions{ + Stdin: true, + }, + }) +} + +func (k *k8sRuntime) CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, ContainerPathStat, error) { + // Test path info. + pathStat, err := k.ContainerStatPath(ctx, cid, srcPath) + if err != nil { + return nil, ContainerPathStat{}, err + } + + // Execute the command in the container, streaming in the tar file + cmdArr := []string{"tar", "cf", "-", srcPath} + + // Pipe tar data to return. + pipeReader, outStream := io.Pipe() + + // Start streaming from the container. + go func() { + defer outStream.Close() + // We need to attach stdout to wait for result. + err = k.containerExec(ctx, k8sPodMainContainerID(cid), cmdArr, k8sStreams{ + out: outStream, + opts: ContainerStreamsOptions{ + Stdout: true, + }, + }) + + if err != nil { + launchr.Log().Debug("failed to copy from container", "cid", cid, "srcPath", srcPath, "err", err) + } + }() + + return &k8sTarPipeReader{pipeReader}, pathStat, nil +} + +func (k *k8sRuntime) ContainerStatPath(ctx context.Context, cid string, path string) (ContainerPathStat, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + done := make(chan error) + statCmd := []string{"sh", "-c", fmt.Sprintf(k8sStatPathScript, path)} + + // Capture output + var stdout bytes.Buffer + go func() { + done <- k.containerExec(ctx, k8sPodMainContainerID(cid), statCmd, k8sStreams{ + out: &stdout, + opts: ContainerStreamsOptions{ + Stdout: true, + }, + }) + }() + + select { + case <-ctx.Done(): + return ContainerPathStat{}, ctx.Err() + case err := <-done: + if err != nil { + return ContainerPathStat{}, err + } + return parseStatOutput(stdout.String(), path), nil + } + +} + +func (k *k8sRuntime) ContainerList(_ context.Context, _ ContainerListOptions) []ContainerListResult { + return nil +} + +func (k *k8sRuntime) ContainerCreate(ctx context.Context, opts ContainerDefinition) (string, error) { + // Generate a unique pod name + namespace := "default" + podName := opts.ContainerName + containerName := podName + + cid := k8sCreateContainerID(namespace, podName, containerName) + + // Prepare environment variables, host aliases and volumes. + hostAliases := k8sHostAliases(opts) + volumes, mounts := k8sVolumesAndMounts(opts) + + // Create the pod definition. + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: podName, + Namespace: namespace, + }, + Spec: corev1.PodSpec{ + HostAliases: hostAliases, + Hostname: opts.Hostname, + RestartPolicy: corev1.RestartPolicyNever, + Volumes: volumes, + Containers: []corev1.Container{ + { + Name: k8sMainPodContainer, + Image: "alpine:latest", + VolumeMounts: mounts, + Command: []string{"sleep"}, + Args: []string{"infinity"}, + }, + }, + }, + } + + // Create the pod + launchr.Log().Debug("creating pod", "namespace", namespace, "pod", podName) + _, err := k.clientset.CoreV1(). + Pods(namespace). + Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + return "", fmt.Errorf("failed to create pod: %w", err) + } + // Wait for pod to be running + launchr.Log().Debug("waiting for pod to start running", "namespace", namespace, "pod", podName) + err = wait.PollUntilContextTimeout(ctx, time.Millisecond*300, time.Second*30, true, func(ctx context.Context) (bool, error) { + pod, err := k.clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return false, nil // Ignore errors and keep trying + } + return pod.Status.Phase == corev1.PodRunning, nil + }) + + if err != nil { + return "", fmt.Errorf("error waiting for pod to run: %w", err) + } + + launchr.Log().Debug("pod is running", "namespace", namespace, "pod", podName) + return cid, err +} + +func (k *k8sRuntime) ContainerStart(ctx context.Context, cid string, opts ContainerDefinition) (<-chan int, *ContainerInOut, error) { + // Create an ephemeral container to run. + err := k.addEphemeralContainer(ctx, cid, opts) + if err != nil { + return nil, nil, err + } + + statusCh := make(chan int) + + // Prepare container io to handle tty. + // Create pipes for stdin, stdout, and stderr. + stdinReader, stdinWriter := io.Pipe() + stdoutReader, stdoutWriter := io.Pipe() + stderrReader, stderrWriter := io.Pipe() + + cio := &ContainerInOut{ + In: stdinWriter, + Out: stdoutReader, + Err: stderrReader, + Opts: opts.Streams, + } + + var resizeCh k8sResizeQueue + if opts.Streams.TTY { + resizeCh = make(k8sResizeQueue, 1) + cio.TtyMonitor = NewTtySizeMonitor(func(_ context.Context, ropts terminalSize) error { + resizeCh <- &remotecommand.TerminalSize{ + Width: uint16(ropts.Width), //nolint:gosec // G115: overflow should be ok + Height: uint16(ropts.Height), //nolint:gosec // G115: overflow should be ok + } + return nil + }) + } + + // Stream container exec. + go func() { + defer close(statusCh) + defer close(resizeCh) + // Close writers when the execution finishes. + defer stdoutWriter.Close() + defer stderrWriter.Close() + + // Send a special signal to start the script after attach. + err := k.ContainerKill(ctx, cid, "USR1") + if err != nil { + launchr.Log().Error("error container start", "error", err, "cid", cid) + if exitErr, ok := err.(exec.CodeExitError); ok { + statusCh <- exitErr.ExitStatus() + } else { + statusCh <- 130 + } + return + } + + // Wait io streaming to fully finish. + err = k.containerAttach(ctx, cid, k8sStreams{ + in: stdinReader, + out: stdoutWriter, + err: stderrWriter, + opts: opts.Streams, + tty: resizeCh, + }) + if err != nil { + launchr.Log().Error("error container attach", "error", err, "cid", cid) + if exitErr, ok := err.(exec.CodeExitError); ok { + statusCh <- exitErr.ExitStatus() + } else { + statusCh <- 130 + } + } else { + statusCh <- 0 + } + }() + + return statusCh, cio, nil +} + +func (k *k8sRuntime) ContainerStop(ctx context.Context, cid string, opts ContainerStopOptions) error { + timeout := 10 * time.Second + if opts.Timeout != nil { + timeout = *opts.Timeout + } + // Try to shut down gracefully within given timeout. + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + errCh := make(chan error, 1) + go func() { + errCh <- k.ContainerKill(ctx, cid, "TERM") + }() + + var err error + select { + case err = <-errCh: + case <-ctx.Done(): + err = ctx.Err() + } + // If failed to shut down, kill the process. + if err != nil { + return k.ContainerKill(ctx, cid, "KILL") + } + return nil +} + +func (k *k8sRuntime) ContainerKill(ctx context.Context, cid, signal string) error { + killCmd := []string{ + "/bin/sh", "-c", + fmt.Sprintf("kill -%s 1", signal), + } + var stdout bytes.Buffer + err := k.containerExec(ctx, cid, killCmd, k8sStreams{ + out: &stdout, + opts: ContainerStreamsOptions{ + Stdout: true, + }, + }) + if err != nil { + return fmt.Errorf("error container kill: %w, message: %s", err, stdout.String()) + } + return err +} + +func (k *k8sRuntime) ContainerRemove(ctx context.Context, cid string) error { + namespace, podName, _ := k8sParseContainerID(cid) + deletePolicy := metav1.DeletePropagationForeground + execOpts := metav1.DeleteOptions{ + PropagationPolicy: &deletePolicy, + } + err := k.clientset.CoreV1().Pods(namespace).Delete(ctx, podName, execOpts) + return err +} + +func (k *k8sRuntime) Close() error { + // Normally all requests are closed immediately. + return nil +} + +func (k *k8sRuntime) containerExec(ctx context.Context, cid string, cmd []string, streams k8sStreams) error { + namespace, podName, containerName := k8sParseContainerID(cid) + + // Create the execution request + req := k.clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(namespace). + SubResource("exec") + + // Set up the exec options + execOptions := &corev1.PodExecOptions{ + Container: containerName, + Command: cmd, + Stdin: streams.opts.Stdin, + Stdout: streams.opts.Stdout, + Stderr: streams.opts.Stderr, + TTY: streams.opts.TTY, + } + + // Add the options to the request + req.VersionedParams(execOptions, scheme.ParameterCodec) + + // Create the executor + executor, err := k8sCreateExecutor(req.URL(), k.config) + if err != nil { + return fmt.Errorf("error creating executor: %w", err) + } + + // Start the exec session + return executor.StreamWithContext(ctx, streams.streamOptions()) +} + +func (k *k8sRuntime) containerAttach(ctx context.Context, cid string, streams k8sStreams) error { + namespace, podName, containerName := k8sParseContainerID(cid) + + // Attach to the pod. + req := k.clientset.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(podName). + Namespace(namespace). + SubResource("attach") + + req.VersionedParams(&corev1.PodAttachOptions{ + Container: containerName, + Stdin: streams.opts.Stdin, + Stdout: streams.opts.Stdout, + Stderr: streams.opts.Stderr, + TTY: streams.opts.TTY, + }, scheme.ParameterCodec) + + executor, err := k8sCreateExecutor(req.URL(), k.config) + if err != nil { + return fmt.Errorf("error creating executor: %w", err) + } + + return executor.StreamWithContext(ctx, streams.streamOptions()) +} + +func (k *k8sRuntime) addEphemeralContainer(ctx context.Context, cid string, opts ContainerDefinition) error { + namespace, podName, containerName := k8sParseContainerID(cid) + _, mounts := k8sVolumesAndMounts(opts) + + cmd := slices.Concat(opts.Entrypoint, opts.Command) + + ephemeralContainer := corev1.EphemeralContainer{ + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: containerName, + Image: opts.Image, + // Wrap the command into a script that will wait until a special signal USR1. + // We do that to not miss any output before the attach. See ContainerStart. + Command: []string{"/bin/sh", "-c", k8sWaitAttachScript, "--"}, + Args: cmd, + WorkingDir: opts.WorkingDir, + VolumeMounts: mounts, + Env: k8sEnvVars(opts), + TTY: opts.Streams.TTY, + Stdin: opts.Streams.Stdin, + }, + } + + // Create patch payload for ephemeral containers + type patchSpec struct { + Spec struct { + EphemeralContainers []corev1.EphemeralContainer `json:"ephemeralContainers"` + } `json:"spec"` + } + + payload := patchSpec{} + payload.Spec.EphemeralContainers = []corev1.EphemeralContainer{ephemeralContainer} + + // Convert to JSON + payloadBytes, err := json.Marshal(payload) + if err != nil { + return fmt.Errorf("failed to marshal patch payload: %w", err) + } + + // Apply the patch - use special ephemeralcontainers subresource + _, err = k.clientset.CoreV1().Pods(namespace).Patch( + ctx, + podName, + types.StrategicMergePatchType, + payloadBytes, + metav1.PatchOptions{}, + "ephemeralcontainers", + ) + if err != nil { + return fmt.Errorf("failed to patch ephemeral container to pod: %w", err) + } + + // Wait until it's created. + return wait.PollUntilContextTimeout(ctx, time.Millisecond*300, time.Second*30, true, func(ctx context.Context) (bool, error) { + pod, err := k.clientset.CoreV1().Pods(namespace).Get(ctx, podName, metav1.GetOptions{}) + if err != nil { + return false, nil + } + + // Check ephemeral container status + for _, containerStatus := range pod.Status.EphemeralContainerStatuses { + if containerStatus.Name == containerName { + if containerStatus.State.Terminated != nil { + return true, fmt.Errorf("ephemeral container %s has terminated with exit code %d", containerName, containerStatus.State.Terminated.ExitCode) + } + waitStatus := containerStatus.State.Waiting + if waitStatus != nil && strings.HasPrefix(waitStatus.Reason, "Err") { + return true, fmt.Errorf("failed to create ephemeral container (%s): %s", waitStatus.Reason, waitStatus.Message) + } + return containerStatus.State.Running != nil, nil + } + } + return false, nil + }) +} diff --git a/pkg/driver/mocks/mock.go b/pkg/driver/mocks/mock.go index c001c5d..8ff6148 100644 --- a/pkg/driver/mocks/mock.go +++ b/pkg/driver/mocks/mock.go @@ -14,8 +14,6 @@ import ( io "io" reflect "reflect" - container "github.com/docker/docker/api/types/container" - image "github.com/docker/docker/api/types/image" driver "github.com/launchrctl/launchr/pkg/driver" gomock "go.uber.org/mock/gomock" ) @@ -58,23 +56,8 @@ func (mr *MockContainerRuntimeMockRecorder) Close() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockContainerRuntime)(nil).Close)) } -// ContainerAttach mocks base method. -func (m *MockContainerRuntime) ContainerAttach(ctx context.Context, cid string, opts container.AttachOptions) (*driver.ContainerInOut, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerAttach", ctx, cid, opts) - ret0, _ := ret[0].(*driver.ContainerInOut) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ContainerAttach indicates an expected call of ContainerAttach. -func (mr *MockContainerRuntimeMockRecorder) ContainerAttach(ctx, cid, opts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerAttach", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerAttach), ctx, cid, opts) -} - // ContainerCreate mocks base method. -func (m *MockContainerRuntime) ContainerCreate(ctx context.Context, opts driver.ContainerCreateOptions) (string, error) { +func (m *MockContainerRuntime) ContainerCreate(ctx context.Context, opts driver.ContainerDefinition) (string, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ContainerCreate", ctx, opts) ret0, _ := ret[0].(string) @@ -88,20 +71,6 @@ func (mr *MockContainerRuntimeMockRecorder) ContainerCreate(ctx, opts any) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerCreate", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerCreate), ctx, opts) } -// ContainerExecResize mocks base method. -func (m *MockContainerRuntime) ContainerExecResize(ctx context.Context, cid string, opts container.ResizeOptions) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerExecResize", ctx, cid, opts) - ret0, _ := ret[0].(error) - return ret0 -} - -// ContainerExecResize indicates an expected call of ContainerExecResize. -func (mr *MockContainerRuntimeMockRecorder) ContainerExecResize(ctx, cid, opts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerExecResize", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerExecResize), ctx, cid, opts) -} - // ContainerKill mocks base method. func (m *MockContainerRuntime) ContainerKill(ctx context.Context, cid, signal string) error { m.ctrl.T.Helper() @@ -131,39 +100,27 @@ func (mr *MockContainerRuntimeMockRecorder) ContainerList(ctx, opts any) *gomock } // ContainerRemove mocks base method. -func (m *MockContainerRuntime) ContainerRemove(ctx context.Context, cid string, opts container.RemoveOptions) error { +func (m *MockContainerRuntime) ContainerRemove(ctx context.Context, cid string) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerRemove", ctx, cid, opts) + ret := m.ctrl.Call(m, "ContainerRemove", ctx, cid) ret0, _ := ret[0].(error) return ret0 } // ContainerRemove indicates an expected call of ContainerRemove. -func (mr *MockContainerRuntimeMockRecorder) ContainerRemove(ctx, cid, opts any) *gomock.Call { +func (mr *MockContainerRuntimeMockRecorder) ContainerRemove(ctx, cid any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerRemove", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerRemove), ctx, cid, opts) -} - -// ContainerResize mocks base method. -func (m *MockContainerRuntime) ContainerResize(ctx context.Context, cid string, opts container.ResizeOptions) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerResize", ctx, cid, opts) - ret0, _ := ret[0].(error) - return ret0 -} - -// ContainerResize indicates an expected call of ContainerResize. -func (mr *MockContainerRuntimeMockRecorder) ContainerResize(ctx, cid, opts any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerResize", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerResize), ctx, cid, opts) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerRemove", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerRemove), ctx, cid) } // ContainerStart mocks base method. -func (m *MockContainerRuntime) ContainerStart(ctx context.Context, cid string, opts driver.ContainerStartOptions) error { +func (m *MockContainerRuntime) ContainerStart(ctx context.Context, cid string, opts driver.ContainerDefinition) (<-chan int, *driver.ContainerInOut, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ContainerStart", ctx, cid, opts) - ret0, _ := ret[0].(error) - return ret0 + ret0, _ := ret[0].(<-chan int) + ret1, _ := ret[1].(*driver.ContainerInOut) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 } // ContainerStart indicates an expected call of ContainerStart. @@ -173,10 +130,10 @@ func (mr *MockContainerRuntimeMockRecorder) ContainerStart(ctx, cid, opts any) * } // ContainerStatPath mocks base method. -func (m *MockContainerRuntime) ContainerStatPath(ctx context.Context, cid, path string) (container.PathStat, error) { +func (m *MockContainerRuntime) ContainerStatPath(ctx context.Context, cid, path string) (driver.ContainerPathStat, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ContainerStatPath", ctx, cid, path) - ret0, _ := ret[0].(container.PathStat) + ret0, _ := ret[0].(driver.ContainerPathStat) ret1, _ := ret[1].(error) return ret0, ret1 } @@ -188,40 +145,25 @@ func (mr *MockContainerRuntimeMockRecorder) ContainerStatPath(ctx, cid, path any } // ContainerStop mocks base method. -func (m *MockContainerRuntime) ContainerStop(ctx context.Context, cid string) error { +func (m *MockContainerRuntime) ContainerStop(ctx context.Context, cid string, opts driver.ContainerStopOptions) error { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerStop", ctx, cid) + ret := m.ctrl.Call(m, "ContainerStop", ctx, cid, opts) ret0, _ := ret[0].(error) return ret0 } // ContainerStop indicates an expected call of ContainerStop. -func (mr *MockContainerRuntimeMockRecorder) ContainerStop(ctx, cid any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerStop", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerStop), ctx, cid) -} - -// ContainerWait mocks base method. -func (m *MockContainerRuntime) ContainerWait(ctx context.Context, cid string, opts driver.ContainerWaitOptions) (<-chan driver.ContainerWaitResponse, <-chan error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ContainerWait", ctx, cid, opts) - ret0, _ := ret[0].(<-chan driver.ContainerWaitResponse) - ret1, _ := ret[1].(<-chan error) - return ret0, ret1 -} - -// ContainerWait indicates an expected call of ContainerWait. -func (mr *MockContainerRuntimeMockRecorder) ContainerWait(ctx, cid, opts any) *gomock.Call { +func (mr *MockContainerRuntimeMockRecorder) ContainerStop(ctx, cid, opts any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerWait", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerWait), ctx, cid, opts) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ContainerStop", reflect.TypeOf((*MockContainerRuntime)(nil).ContainerStop), ctx, cid, opts) } // CopyFromContainer mocks base method. -func (m *MockContainerRuntime) CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, container.PathStat, error) { +func (m *MockContainerRuntime) CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, driver.ContainerPathStat, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CopyFromContainer", ctx, cid, srcPath) ret0, _ := ret[0].(io.ReadCloser) - ret1, _ := ret[1].(container.PathStat) + ret1, _ := ret[1].(driver.ContainerPathStat) ret2, _ := ret[2].(error) return ret0, ret1, ret2 } @@ -233,7 +175,7 @@ func (mr *MockContainerRuntimeMockRecorder) CopyFromContainer(ctx, cid, srcPath } // CopyToContainer mocks base method. -func (m *MockContainerRuntime) CopyToContainer(ctx context.Context, cid, path string, content io.Reader, opts container.CopyToContainerOptions) error { +func (m *MockContainerRuntime) CopyToContainer(ctx context.Context, cid, path string, content io.Reader, opts driver.CopyToContainerOptions) error { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "CopyToContainer", ctx, cid, path, content, opts) ret0, _ := ret[0].(error) @@ -262,7 +204,7 @@ func (mr *MockContainerRuntimeMockRecorder) ImageEnsure(ctx, opts any) *gomock.C } // ImageRemove mocks base method. -func (m *MockContainerRuntime) ImageRemove(ctx context.Context, image string, opts image.RemoveOptions) (*driver.ImageRemoveResponse, error) { +func (m *MockContainerRuntime) ImageRemove(ctx context.Context, image string, opts driver.ImageRemoveOptions) (*driver.ImageRemoveResponse, error) { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ImageRemove", ctx, image, opts) ret0, _ := ret[0].(*driver.ImageRemoveResponse) diff --git a/pkg/driver/tty.go b/pkg/driver/tty.go index 8340f75..98c963f 100644 --- a/pkg/driver/tty.go +++ b/pkg/driver/tty.go @@ -12,72 +12,76 @@ import ( "github.com/launchrctl/launchr/internal/launchr" ) -type resizeTtyFn func(ctx context.Context, d ContainerRunner, cli launchr.Streams, id string, isExec bool) error +type terminalSize struct { + Height uint + Width uint +} + +type resizeTtyFn func(ctx context.Context, ropts terminalSize) error -// resizeTtyTo resizes tty to specific height and width -func resizeTtyTo(ctx context.Context, d ContainerRunner, id string, height, width uint, isExec bool) error { +// resizeTty is to resize the tty with cli out's tty size +func resizeTty(ctx context.Context, streams launchr.Streams, resizeFn resizeTtyFn) error { + height, width := streams.Out().GetTtySize() if height == 0 && width == 0 { return nil } - options := ResizeOptions{ + err := resizeFn(ctx, terminalSize{ Height: height, Width: width, - } - - var err error - if isExec { - err = d.ContainerExecResize(ctx, id, options) - } else { - err = d.ContainerResize(ctx, id, options) - } - + }) if err != nil { launchr.Log().Debug("error tty resize", "error", err) + } else if errCtx := ctx.Err(); errCtx != nil { + err = errCtx } return err } -// resizeTty is to resize the tty with cli out's tty size -func resizeTty(ctx context.Context, d ContainerRunner, cli launchr.Streams, id string, isExec bool) error { - height, width := cli.Out().GetTtySize() - return resizeTtyTo(ctx, d, id, height, width, isExec) -} - // initTtySize is to init the tty's size to the same as the window, if there is an error, it will retry 10 times. -func initTtySize(ctx context.Context, d ContainerRunner, cli launchr.Streams, id string, isExec bool, resizeTtyFunc resizeTtyFn) { - rttyFunc := resizeTtyFunc - if rttyFunc == nil { - rttyFunc = resizeTty - } - if err := rttyFunc(ctx, d, cli, id, isExec); err != nil { +func initTtySize(ctx context.Context, streams launchr.Streams, resizeFn resizeTtyFn) { + if err := resizeTty(ctx, streams, resizeFn); err != nil { go func() { var err error for retry := 0; retry < 10; retry++ { time.Sleep(time.Duration(retry+1) * 10 * time.Millisecond) - if err = rttyFunc(ctx, d, cli, id, isExec); err == nil { + if err = resizeTty(ctx, streams, resizeFn); err == nil { break } } if err != nil { - launchr.Log().Error("failed to resize tty, using default size", "err", cli.Err()) + launchr.Log().Error("failed to resize tty, using default size", "err", streams.Err()) } }() } } -// MonitorTtySize updates the container tty size when the terminal tty changes size -func MonitorTtySize(ctx context.Context, d ContainerRunner, cli launchr.Streams, id string, isExec bool) error { - initTtySize(ctx, d, cli, id, isExec, resizeTty) +// TtySizeMonitor updates the container tty size when the terminal tty changes size +type TtySizeMonitor struct { + resizeFn resizeTtyFn +} + +// NewTtySizeMonitor creates a new TtySizeMonitor. +func NewTtySizeMonitor(resizeFn resizeTtyFn) *TtySizeMonitor { + return &TtySizeMonitor{ + resizeFn: resizeFn, + } +} + +// Start starts tty size watching. +func (t *TtySizeMonitor) Start(ctx context.Context, streams launchr.Streams) { + if t == nil { + return + } + initTtySize(ctx, streams, t.resizeFn) if runtime.GOOS == "windows" { go func() { - prevH, prevW := cli.Out().GetTtySize() + prevH, prevW := streams.Out().GetTtySize() for { - time.Sleep(time.Millisecond * 250) - h, w := cli.Out().GetTtySize() + h, w := streams.Out().GetTtySize() if prevW != w || prevH != h { - err := resizeTty(ctx, d, cli, id, isExec) + err := resizeTty(ctx, streams, t.resizeFn) if err != nil { // Stop monitoring return @@ -91,8 +95,9 @@ func MonitorTtySize(ctx context.Context, d ContainerRunner, cli launchr.Streams, sigchan := make(chan os.Signal, 1) gosignal.Notify(sigchan, signal.SIGWINCH) go func() { + defer gosignal.Stop(sigchan) for range sigchan { - err := resizeTty(ctx, d, cli, id, isExec) + err := resizeTty(ctx, streams, t.resizeFn) if err != nil { // Stop monitoring return @@ -100,5 +105,4 @@ func MonitorTtySize(ctx context.Context, d ContainerRunner, cli launchr.Streams, } }() } - return nil } diff --git a/pkg/driver/type.go b/pkg/driver/type.go index e12c083..bbe4412 100644 --- a/pkg/driver/type.go +++ b/pkg/driver/type.go @@ -4,12 +4,13 @@ package driver import ( "context" "io" + "os" "path/filepath" "time" - typescontainer "github.com/docker/docker/api/types/container" - typesimage "github.com/docker/docker/api/types/image" "gopkg.in/yaml.v3" + + "github.com/launchrctl/launchr/internal/launchr" ) // ContainerRunner defines common interface for container environments. @@ -19,15 +20,11 @@ type ContainerRunner interface { CopyFromContainer(ctx context.Context, cid, srcPath string) (io.ReadCloser, ContainerPathStat, error) ContainerStatPath(ctx context.Context, cid string, path string) (ContainerPathStat, error) ContainerList(ctx context.Context, opts ContainerListOptions) []ContainerListResult - ContainerCreate(ctx context.Context, opts ContainerCreateOptions) (string, error) - ContainerStart(ctx context.Context, cid string, opts ContainerStartOptions) error - ContainerWait(ctx context.Context, cid string, opts ContainerWaitOptions) (<-chan ContainerWaitResponse, <-chan error) - ContainerAttach(ctx context.Context, cid string, opts ContainerAttachOptions) (*ContainerInOut, error) - ContainerStop(ctx context.Context, cid string) error + ContainerCreate(ctx context.Context, opts ContainerDefinition) (string, error) + ContainerStart(ctx context.Context, cid string, opts ContainerDefinition) (<-chan int, *ContainerInOut, error) + ContainerStop(ctx context.Context, cid string, opts ContainerStopOptions) error ContainerKill(ctx context.Context, cid, signal string) error - ContainerRemove(ctx context.Context, cid string, opts ContainerRemoveOptions) error - ContainerResize(ctx context.Context, cid string, opts ResizeOptions) error - ContainerExecResize(ctx context.Context, cid string, opts ResizeOptions) error + ContainerRemove(ctx context.Context, cid string) error Close() error } @@ -43,9 +40,6 @@ type ContainerRunnerSELinux interface { IsSELinuxSupported(ctx context.Context) bool } -// ResizeOptions is a struct for terminal resizing. -type ResizeOptions = typescontainer.ResizeOptions - // BuildDefinition stores image build definition. type BuildDefinition struct { Context string `yaml:"context"` @@ -97,7 +91,9 @@ type ImageOptions struct { } // ImageRemoveOptions stores options for removing an image. -type ImageRemoveOptions = typesimage.RemoveOptions +type ImageRemoveOptions struct { + Force bool +} // ImageStatus defines image status on local machine. type ImageStatus int64 @@ -124,6 +120,7 @@ type SystemInfo struct { NCPU int MemTotal int64 SecurityOptions []string + Remote bool // Remote defines if local or remote containers are spawned. } // ContainerListOptions stores options to request container list. @@ -138,10 +135,30 @@ type ContainerListResult struct { Status string } -// ImageStatusResponse stores response when getting the image. +// ImageStatusResponse stores the response when getting the image. type ImageStatusResponse struct { Status ImageStatus - Progress io.ReadCloser + Progress *ImageProgressStream +} + +// ImageProgressStream holds Image progress reader and a way to stream it to the given output. +type ImageProgressStream struct { + io.ReadCloser + streamer func(io.Reader, *launchr.Out) error +} + +// Stream outputs progress to the given output. +func (p *ImageProgressStream) Stream(out *launchr.Out) error { + if p.streamer == nil { + _, err := io.Copy(out, p.ReadCloser) + return err + } + return p.streamer(p.ReadCloser, out) +} + +// Close closes the reader. +func (p *ImageProgressStream) Close() error { + return p.ReadCloser.Close() } // ImageRemoveResponse stores response when removing the image. @@ -150,74 +167,58 @@ type ImageRemoveResponse struct { } // ContainerPathStat is a type alias for container path stat result. -type ContainerPathStat = typescontainer.PathStat +type ContainerPathStat struct { + Name string + Size int64 + Mode os.FileMode + Mtime time.Time + LinkTarget string +} // CopyToContainerOptions is a type alias for container copy to container options. -type CopyToContainerOptions = typescontainer.CopyToContainerOptions - -// NetworkMode is a type alias for container Network mode. -type NetworkMode = typescontainer.NetworkMode - -// Network modes. -const ( - NetworkModeHost NetworkMode = "host" // NetworkModeHost for host network. -) +type CopyToContainerOptions struct { + AllowOverwriteDirWithFile bool + CopyUIDGID bool +} -// ContainerCreateOptions stores options for creating a new container. -type ContainerCreateOptions struct { +// ContainerDefinition stores options for creating a new container. +type ContainerDefinition struct { Hostname string ContainerName string Image string - Cmd []string - WorkingDir string - Binds []string - Volumes map[string]struct{} - NetworkMode NetworkMode - ExtraHosts []string - AutoRemove bool - OpenStdin bool - StdinOnce bool - AttachStdin bool - AttachStdout bool - AttachStderr bool - Tty bool - Env []string - User string - Entrypoint []string -} -// ContainerStartOptions stores options for starting a container. -type ContainerStartOptions struct { -} + Entrypoint []string + Command []string + WorkingDir string -// ContainerWaitOptions stores options for waiting while container works. -type ContainerWaitOptions struct { - Condition WaitCondition -} + // @todo review binds and volumes, because binds won't work for remote environments. + Binds []string + Volumes []ContainerVolume -// WaitCondition is a type for available wait conditions. -type WaitCondition = typescontainer.WaitCondition + Streams ContainerStreamsOptions -// Container wait conditions. -const ( - WaitConditionNotRunning WaitCondition = typescontainer.WaitConditionNotRunning // WaitConditionNotRunning when container exits when running. - WaitConditionNextExit WaitCondition = typescontainer.WaitConditionNextExit // WaitConditionNextExit when container exits after next start. - WaitConditionRemoved WaitCondition = typescontainer.WaitConditionRemoved // WaitConditionRemoved when container is removed. -) + Env []string + User string + ExtraHosts []string +} -// ContainerWaitResponse stores response given by wait result. -type ContainerWaitResponse struct { - StatusCode int - Error error +// ContainerVolume stores volume definition for a container. +type ContainerVolume struct { + // Name is a volume name. Leave empty for an anonymous volume. + Name string + // MountPath is a path within the container at which the volume should be mounted. Must not contain ':'. + MountPath string } -// ContainerAttachOptions stores options for attaching to a running container. -type ContainerAttachOptions = typescontainer.AttachOptions +// ContainerStreamsOptions stores options for attaching to streams of a running container. +type ContainerStreamsOptions struct { + TTY bool + Stdin bool + Stdout bool + Stderr bool +} // ContainerStopOptions stores options to stop a container. type ContainerStopOptions struct { Timeout *time.Duration } - -// ContainerRemoveOptions stores options to remove a container. -type ContainerRemoveOptions = typescontainer.RemoveOptions diff --git a/pkg/driver/utils.go b/pkg/driver/utils.go deleted file mode 100644 index d7a90b1..0000000 --- a/pkg/driver/utils.go +++ /dev/null @@ -1,30 +0,0 @@ -package driver - -import ( - "io" - - "github.com/docker/docker/pkg/jsonmessage" - "github.com/docker/docker/pkg/namesgenerator" - - "github.com/launchrctl/launchr/internal/launchr" -) - -// GetRandomName generates a random human-friendly name. -func GetRandomName(retry int) string { - return namesgenerator.GetRandomName(retry) -} - -// DockerDisplayJSONMessages prints docker json output to streams. -func DockerDisplayJSONMessages(in io.Reader, streams launchr.Streams) error { - err := jsonmessage.DisplayJSONMessagesToStream(in, streams.Out(), nil) - if err != nil { - if jerr, ok := err.(*jsonmessage.JSONError); ok { - // If no error code is set, default to 1 - if jerr.Code == 0 { - jerr.Code = 1 - } - return jerr - } - } - return err -}