mirror of
https://github.com/psviderski/uncloud.git
synced 2026-08-26 19:13:34 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbdeedb44e | ||
|
|
3e64af910a | ||
|
|
fcdbf785bb | ||
|
|
7ebcba7c36 | ||
|
|
90d244fea8 | ||
|
|
497b9483f6 | ||
|
|
e33fd40033 | ||
|
|
2b93a1c089 | ||
|
|
15d4ef5518 | ||
|
|
2f5f515696 | ||
|
|
30bb33f1a0 | ||
|
|
7bff706aa0 | ||
|
|
b0cd752619 | ||
|
|
52dd0c71a5 | ||
|
|
2d292ed053 | ||
|
|
9770151583 | ||
|
|
89db50e8df | ||
|
|
3ac5992e1d | ||
|
|
8c4f21ff81 | ||
|
|
4de068c46e | ||
|
|
b6dfc2175f | ||
|
|
48d2239f5d | ||
|
|
06d988583d | ||
|
|
abea3e229e | ||
|
|
1c3f210848 | ||
|
|
722615cd9b | ||
|
|
809d1eca77 | ||
|
|
6e4439bf49 | ||
|
|
882f2f5d03 | ||
|
|
dde23b5549 | ||
|
|
c3ecd378f8 | ||
|
|
6da8e98c62 | ||
|
|
4d591f9efc | ||
|
|
26c3699a9e | ||
|
|
328ace1fd1 | ||
|
|
91bf135a4f | ||
|
|
e2b3260926 | ||
|
|
179289b504 | ||
|
|
bd8a843972 | ||
|
|
7e366ff980 | ||
|
|
d0c23fa840 | ||
|
|
4bc47af05b | ||
|
|
65c3e0a68d | ||
|
|
5c4f49b685 | ||
|
|
ba6290d616 | ||
|
|
6af7a9861d | ||
|
|
3b4bcb7932 | ||
|
|
0153e9f9e2 | ||
|
|
9a0b1a4ce3 | ||
|
|
e251dd950b | ||
|
|
40862df038 | ||
|
|
dc569175ef | ||
|
|
6ba98e4576 | ||
|
|
75b932b66e | ||
|
|
5aa85ab501 | ||
|
|
521ccf2026 | ||
|
|
e8b4dd0bd1 | ||
|
|
265042e2c8 | ||
|
|
1c85112d4f | ||
|
|
0fc5032b27 | ||
|
|
893ab1f9f5 | ||
|
|
a30ed600b6 | ||
|
|
a2c6cbe633 | ||
|
|
1f9c2c873d | ||
|
|
509c9eb7d3 | ||
|
|
fba03b3df7 | ||
|
|
c6d18f5d49 | ||
|
|
3e804d9ecc | ||
|
|
784d86d8e8 | ||
|
|
3bac1e61b9 | ||
|
|
6d2e100d63 | ||
|
|
cce81ecaad | ||
|
|
f8bf6bccd3 | ||
|
|
82fd9744bf | ||
|
|
cdff036935 | ||
|
|
458d282357 | ||
|
|
59074a275f | ||
|
|
92975eec51 | ||
|
|
e0d11a8abc | ||
|
|
fa004d63bb | ||
|
|
a217643ac9 | ||
|
|
fc3297c7b7 | ||
|
|
684f0d38ee | ||
|
|
332e7bca90 | ||
|
|
ae458d260f | ||
|
|
6b758c810e | ||
|
|
ffed906cef | ||
|
|
bcc504b8cc | ||
|
|
782f4cb20b | ||
|
|
aebd2ac232 | ||
|
|
2969b40ad4 | ||
|
|
e0045c7570 | ||
|
|
bc6f2fd49d | ||
|
|
cd25b973ea | ||
|
|
27bbbe7186 | ||
|
|
20cf98d42e | ||
|
|
63c4de512e | ||
|
|
337c15de35 | ||
|
|
2d361d3ee6 | ||
|
|
977fdf3cc3 | ||
|
|
9f6880b701 | ||
|
|
1a1afece7d | ||
|
|
08f233c5b1 | ||
|
|
9438654d99 | ||
|
|
60926f64cc |
@@ -40,7 +40,7 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
make ucind-image
|
make ucind-image
|
||||||
make test
|
make test
|
||||||
timeout-minutes: 10
|
timeout-minutes: 15
|
||||||
|
|
||||||
check-protobuf:
|
check-protobuf:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|||||||
+14
@@ -40,6 +40,20 @@ checksum = "sha256:cd4dd53fa09b6646baff5fd22b8c64d91db02c21c7496df27992d75d34fee
|
|||||||
size = 13542526
|
size = 13542526
|
||||||
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
|
url = "https://github.com/golangci/golangci-lint/releases/download/v2.4.0/golangci-lint-2.4.0-darwin-arm64.tar.gz"
|
||||||
|
|
||||||
|
[[tools.node]]
|
||||||
|
version = "22.20.0"
|
||||||
|
backend = "core:node"
|
||||||
|
|
||||||
|
[tools.node.platforms.linux-x64]
|
||||||
|
checksum = "sha256:eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
|
||||||
|
size = 56645685
|
||||||
|
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-linux-x64.tar.gz"
|
||||||
|
|
||||||
|
[tools.node.platforms.macos-arm64]
|
||||||
|
checksum = "sha256:cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
|
||||||
|
size = 49838299
|
||||||
|
url = "https://nodejs.org/dist/v22.20.0/node-v22.20.0-darwin-arm64.tar.gz"
|
||||||
|
|
||||||
[[tools.protoc]]
|
[[tools.protoc]]
|
||||||
version = "27.3"
|
version = "27.3"
|
||||||
backend = "aqua:protocolbuffers/protobuf/protoc"
|
backend = "aqua:protocolbuffers/protobuf/protoc"
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ experimental = true
|
|||||||
"aqua:vektra/mockery" = "3.5.3"
|
"aqua:vektra/mockery" = "3.5.3"
|
||||||
go = "1.25.1"
|
go = "1.25.1"
|
||||||
golangci-lint = "2.4.0"
|
golangci-lint = "2.4.0"
|
||||||
|
node = "22.20.0"
|
||||||
protoc = "27.3"
|
protoc = "27.3"
|
||||||
protoc-gen-go = "1.34.2"
|
protoc-gen-go = "1.34.2"
|
||||||
protoc-gen-go-grpc = "1.5.1"
|
protoc-gen-go-grpc = "1.5.1"
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ This document provides comprehensive information about the Uncloud project for A
|
|||||||
- **`scripts/`**: Installation and utility scripts
|
- **`scripts/`**: Installation and utility scripts
|
||||||
- **`test/`**: Test suites and test infrastructure
|
- **`test/`**: Test suites and test infrastructure
|
||||||
- **`website/`**: Documentation website (Docusaurus)
|
- **`website/`**: Documentation website (Docusaurus)
|
||||||
|
|
||||||
|
- `landing/`: Landing page
|
||||||
|
- `docs/`: User documentation
|
||||||
|
|
||||||
- **`misc/`**: Design documents and guides
|
- **`misc/`**: Design documents and guides
|
||||||
|
|
||||||
## Key Technologies
|
## Key Technologies
|
||||||
@@ -184,9 +188,11 @@ uc context use <name> # Switch context
|
|||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
- Unit tests alongside source files (`*_test.go`)
|
- Test files and locations
|
||||||
- Integration tests in `test/e2e/`
|
- Unit tests alongside source files (`*_test.go`)
|
||||||
- Test fixtures in `test/fixtures/`
|
- Integration tests in `test/e2e/`
|
||||||
|
- Test fixtures in `test/fixtures/`
|
||||||
|
- Use table driven tests whenever possible
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
|
|
||||||
@@ -233,7 +239,7 @@ uc context use <name> # Switch context
|
|||||||
- `internal/machine/machine.go`: Core machine management
|
- `internal/machine/machine.go`: Core machine management
|
||||||
- `pkg/api/`: Public API definitions
|
- `pkg/api/`: Public API definitions
|
||||||
- `misc/design.md`: Architecture and design philosophy
|
- `misc/design.md`: Architecture and design philosophy
|
||||||
- `README.md`: User-facing documentation
|
- `README.md`: Repository README
|
||||||
|
|
||||||
### Configuration Files
|
### Configuration Files
|
||||||
|
|
||||||
@@ -242,3 +248,11 @@ uc context use <name> # Switch context
|
|||||||
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
- `Dockerfile`: Container build instructions forUncloud-in-Docker (used for testing)
|
||||||
|
|
||||||
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
This document should help AI assistants understand the project structure, make informed suggestions, and contribute effectively to the Uncloud codebase.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
Instructions when generating documentation:
|
||||||
|
|
||||||
|
- Use conversational language – write as if you were speaking to a friend.
|
||||||
|
- Keep sentences simple, optimize for clarity and understanding.
|
||||||
|
- Place the subject before the action whenever possible. Example: prefer “The function loads data” over “Data is loaded by the function.”
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@ COPY go.mod go.sum ./
|
|||||||
RUN go mod download && go mod verify
|
RUN go mod download && go mod verify
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd cmd/uncloudd/main.go
|
RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o uncloudd ./cmd/uncloudd
|
||||||
|
|
||||||
|
|
||||||
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
FROM alpine:${ALPINE_VERSION} AS corrosion-download
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ demo-reset:
|
|||||||
|
|
||||||
.PHONY: ucind-cluster
|
.PHONY: ucind-cluster
|
||||||
ucind-cluster:
|
ucind-cluster:
|
||||||
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m 3
|
go run ./cmd/ucind cluster rm && go run ./cmd/ucind cluster create -m $(if $(MACHINES_COUNT),$(MACHINES_COUNT),3)
|
||||||
|
|
||||||
.PHONY: proto
|
.PHONY: proto
|
||||||
proto:
|
proto:
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ complexity of Kubernetes.
|
|||||||
|
|
||||||
## ✨ Features
|
## ✨ Features
|
||||||
|
|
||||||
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment —
|
* **Deploy anywhere**: Combine cloud VMs, dedicated servers, and bare metal into a unified computing environment,
|
||||||
regardless of location or provider.
|
regardless of location or provider.
|
||||||
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
* **Docker Compose**: Familiar [Docker Compose](https://compose-spec.io/) format for defining services and volumes. No
|
||||||
need to learn a new bespoke DSL.
|
need to learn a new bespoke DSL.
|
||||||
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
* **Zero-downtime deployments**: Rolling updates without service interruption. Automatic rollback on failure is coming
|
||||||
soon.
|
soon.
|
||||||
|
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Build and push your Docker images directly to
|
||||||
|
your machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
||||||
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
* **Service discovery**: Built-in DNS server resolves service names to container IPs.
|
||||||
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
* **Persistent storage**: Run stateful services with Docker volumes managed across machines.
|
||||||
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
* **Zero-config private network**: Automatic WireGuard mesh with peer discovery and NAT traversal. Containers get unique
|
||||||
@@ -45,11 +47,6 @@ complexity of Kubernetes.
|
|||||||
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
* **Docker-like CLI**: Familiar commands for managing both infrastructure and applications.
|
||||||
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
* **Remote management**: Control your entire infrastructure through SSH access to any single machine in the cluster.
|
||||||
|
|
||||||
### 🚀 Coming soon
|
|
||||||
|
|
||||||
* **[Unregistry](https://github.com/psviderski/unregistry) integration**: Push your Docker images directly to your
|
|
||||||
machines without an external registry. It will transfer only the missing layers, making it fast and efficient.
|
|
||||||
|
|
||||||
## 🎬 Quick demo
|
## 🎬 Quick demo
|
||||||
|
|
||||||
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
The screenshot below demonstrates how I use Uncloud to deploy https://uncloud.run website to 2 remote machines from
|
||||||
@@ -67,6 +64,12 @@ on-premises in just a couple minutes.
|
|||||||
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
<img src="https://embed-ssl.wistia.com/deliveries/3cf7014a48b93afc556444bed3e39a8c.jpg?image_crop_resized=900x526&image_play_button_rounded=true&image_play_button_size=2x&image_play_button_color=18181Be0" alt="Uncloud demo" width="450" height="263" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
<br>
|
||||||
|
|
||||||
|
> **📚 Want more examples?** Check out the [**uncloud-recipes**](https://github.com/psviderski/uncloud-recipes)
|
||||||
|
> repository for community recipes and templates for deploying popular services on Uncloud.
|
||||||
|
|
||||||
## 💫 Why Uncloud?
|
## 💫 Why Uncloud?
|
||||||
|
|
||||||
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
Modern cloud platforms like Heroku and Render offer amazing developer experiences but at a premium price. Traditional
|
||||||
|
|||||||
+70
-35
@@ -10,68 +10,103 @@ import (
|
|||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewBuildCommand creates a new command to build services from a Compose file.
|
type buildOptions struct {
|
||||||
|
cli.BuildServicesOptions
|
||||||
|
files []string
|
||||||
|
profiles []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBuildCommand creates a new command to build images for services from a Compose file.
|
||||||
func NewBuildCommand() *cobra.Command {
|
func NewBuildCommand() *cobra.Command {
|
||||||
opts := cli.BuildOptions{}
|
opts := buildOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "build [FLAGS] [SERVICE...]",
|
Use: "build [FLAGS] [SERVICE...]",
|
||||||
Short: "Build services from a Compose file.",
|
Short: "Build services from a Compose file.",
|
||||||
|
Long: `Build images for services from a Compose file using local Docker.
|
||||||
|
|
||||||
|
By default, built images remain on the local Docker host. Use --push to upload them
|
||||||
|
to cluster machines or --push-registry to upload them to external registries.`,
|
||||||
|
Example: ` # Build all services that have a build section in compose.yaml.
|
||||||
|
uc build
|
||||||
|
|
||||||
|
# Build specific services that have a build section.
|
||||||
|
uc build web api
|
||||||
|
|
||||||
|
# Build services and push images to all cluster machines or service x-machines if specified.
|
||||||
|
uc build --push
|
||||||
|
|
||||||
|
# Build services and push images to specific machines.
|
||||||
|
uc build --push -m machine1,machine2
|
||||||
|
|
||||||
|
# Build services and push images to external registries (e.g., Docker Hub).
|
||||||
|
uc build --push-registry
|
||||||
|
|
||||||
|
# Build services with build arguments, pull newer base images before building, and don't use cache.
|
||||||
|
uc build --build-arg NODE_VERSION=24 --build-arg ENV=production --no-cache --pull`,
|
||||||
RunE: func(cmd *cobra.Command, args []string) error {
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.Services = args
|
||||||
if len(args) > 0 {
|
|
||||||
opts.Services = args
|
|
||||||
}
|
|
||||||
|
|
||||||
return runBuild(cmd.Context(), uncli, opts)
|
return runBuild(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
cmd.Flags().StringSliceVarP(&opts.Files, "file", "f", nil,
|
cmd.Flags().StringArrayVar(&opts.BuildArgs, "build-arg", nil,
|
||||||
"One or more Compose files to build (default compose.yaml)")
|
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||||
cmd.Flags().StringSliceVarP(&opts.Profiles, "profile", "p", nil,
|
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||||
|
cmd.Flags().BoolVar(&opts.Check, "check", false,
|
||||||
|
"Check the build configuration for services without building them.")
|
||||||
|
cmd.Flags().BoolVar(&opts.Deps, "deps", false,
|
||||||
|
"Also build services declared as dependencies of the selected services.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
|
"One or more Compose files to build. (default compose.yaml)")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.Machines, "machine", "m", nil,
|
||||||
|
"Machine names or IDs to push the built images to (requires --push).\n"+
|
||||||
|
"Can be specified multiple times or as a comma-separated list. (default is all machines or x-machines)")
|
||||||
|
cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false,
|
||||||
|
"Do not use cache when building images.")
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
"One or more Compose profiles to enable.")
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().BoolVarP(&opts.Push, "push", "P", false,
|
cmd.Flags().BoolVar(&opts.Pull, "pull", false,
|
||||||
"Push built images to the registry after building. (default false)")
|
"Always attempt to pull newer versions of base images before building.")
|
||||||
cmd.Flags().BoolVarP(&opts.NoCache, "no-cache", "n", false,
|
cmd.Flags().BoolVar(&opts.PushCluster, "push", false,
|
||||||
"Do not use cache when building images. (default false)")
|
"Upload the built images to cluster machines after building.\n"+
|
||||||
|
"Use --machine to specify which machines. (default is all machines)")
|
||||||
|
cmd.Flags().BoolVar(&opts.PushRegistry, "push-registry", false,
|
||||||
|
"Upload the built images to external registries (e.g., Docker Hub) after building.")
|
||||||
|
cmd.Flags().StringVarP(&opts.Context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: deduplicate with a similar functino for deploy options
|
// runBuild parses the Compose file(s) and builds the images for selected services.
|
||||||
// projectOpts returns the project options for the Compose file(s).
|
func runBuild(ctx context.Context, uncli *cli.CLI, opts buildOptions) error {
|
||||||
func projectOptsFromBuildOpts(opts cli.BuildOptions) []composecli.ProjectOptionsFn {
|
// Validate push flags.
|
||||||
projectOpts := []composecli.ProjectOptionsFn{}
|
if opts.PushCluster && opts.PushRegistry {
|
||||||
|
return fmt.Errorf("cannot specify both --push and --push-registry: choose one push target")
|
||||||
if len(opts.Profiles) > 0 {
|
|
||||||
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.Profiles...))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return projectOpts
|
machines := cli.ExpandCommaSeparatedValues(opts.Machines)
|
||||||
}
|
// Special handling for an explicit "all" keyword to push to all machines.
|
||||||
|
if len(machines) == 1 && machines[0] == "all" {
|
||||||
|
machines = nil
|
||||||
|
}
|
||||||
|
opts.Machines = machines
|
||||||
|
|
||||||
// runBuild parses the Compose file(s), builds the services, and pushes them if requested.
|
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
func runBuild(ctx context.Context, uncli *cli.CLI, opts cli.BuildOptions) error {
|
|
||||||
projectOpts := projectOptsFromBuildOpts(opts)
|
|
||||||
project, err := compose.LoadProject(ctx, opts.Files, projectOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(opts.Services) > 0 {
|
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||||
project, err = project.WithSelectedServices(opts.Services)
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("determine services to build: %w", err)
|
||||||
return fmt.Errorf("select services: %w", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
|
||||||
|
|
||||||
if len(servicesToBuild) == 0 {
|
if len(servicesToBuild) == 0 {
|
||||||
fmt.Println("No services to build.")
|
fmt.Println("No services to build.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return cli.BuildServices(ctx, servicesToBuild, opts)
|
return uncli.BuildServices(ctx, project, opts.BuildServicesOptions)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,8 +44,8 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
cmd.Flags().StringVar(&opts.image, "image", "",
|
cmd.Flags().StringVar(&opts.image, "image", "",
|
||||||
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
"Caddy Docker image to deploy. (default caddy:LATEST_VERSION)")
|
||||||
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
"Machine names to deploy to. Can be specified multiple times or as a comma-separated "+
|
"Machine names or IDs to deploy to. Can be specified multiple times or as a comma-separated "+
|
||||||
"list of machine names. (default is all machines)")
|
"list. (default is all machines)")
|
||||||
cmd.Flags().StringVarP(
|
cmd.Flags().StringVarP(
|
||||||
&opts.context, "context", "c", "",
|
&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context to deploy to. (default is the current context)",
|
"Name of the cluster context to deploy to. (default is the current context)",
|
||||||
|
|||||||
+61
-27
@@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
@@ -17,6 +18,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type deployOptions struct {
|
type deployOptions struct {
|
||||||
|
cli.BuildServicesOptions
|
||||||
|
|
||||||
files []string
|
files []string
|
||||||
profiles []string
|
profiles []string
|
||||||
services []string
|
services []string
|
||||||
@@ -37,21 +40,25 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
cli.BindEnvToFlag(cmd, "yes", "UNCLOUD_AUTO_CONFIRM")
|
||||||
|
|
||||||
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
opts.services = args
|
||||||
if len(args) > 0 {
|
|
||||||
opts.services = args
|
|
||||||
}
|
|
||||||
|
|
||||||
return runDeploy(cmd.Context(), uncli, opts)
|
return runDeploy(cmd.Context(), uncli, opts)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringArrayVar(&opts.BuildServicesOptions.BuildArgs, "build-arg", nil,
|
||||||
|
"Set a build-time variable for services. Used in Dockerfiles that declare the variable with ARG.\n"+
|
||||||
|
"Can be specified multiple times. Format: --build-arg VAR=VALUE")
|
||||||
|
cmd.Flags().BoolVar(&opts.BuildServicesOptions.Pull, "build-pull", false,
|
||||||
|
"Always attempt to pull newer versions of base images before building service images.")
|
||||||
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
"Name of the cluster context to deploy to (default is the current context)")
|
"Name of the cluster context to deploy to (default is the current context)")
|
||||||
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
cmd.Flags().StringSliceVarP(&opts.files, "file", "f", nil,
|
||||||
"One or more Compose files to deploy services from. (default compose.yaml)")
|
"One or more Compose files to deploy services from. (default compose.yaml)")
|
||||||
cmd.Flags().BoolVarP(&opts.noBuild, "no-build", "n", false,
|
cmd.Flags().BoolVar(&opts.noBuild, "no-build", false,
|
||||||
"Do not build images before deploying services. (default false)")
|
"Do not build new images before deploying services.")
|
||||||
|
cmd.Flags().BoolVar(&opts.BuildServicesOptions.NoCache, "no-cache", false,
|
||||||
|
"Do not use cache when building images.")
|
||||||
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
cmd.Flags().StringSliceVarP(&opts.profiles, "profile", "p", nil,
|
||||||
"One or more Compose profiles to enable.")
|
"One or more Compose profiles to enable.")
|
||||||
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
cmd.Flags().BoolVar(&opts.recreate, "recreate", false,
|
||||||
@@ -66,22 +73,9 @@ func NewDeployCommand() *cobra.Command {
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// projectOpts returns the project options for the Compose file(s).
|
|
||||||
func projectOpts(opts deployOptions) []composecli.ProjectOptionsFn {
|
|
||||||
projectOpts := []composecli.ProjectOptionsFn{}
|
|
||||||
|
|
||||||
if len(opts.profiles) > 0 {
|
|
||||||
projectOpts = append(projectOpts, composecli.WithDefaultProfiles(opts.profiles...))
|
|
||||||
}
|
|
||||||
|
|
||||||
return projectOpts
|
|
||||||
}
|
|
||||||
|
|
||||||
// runDeploy parses the Compose file(s) and deploys the services.
|
// runDeploy parses the Compose file(s) and deploys the services.
|
||||||
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
||||||
projectOpts := projectOpts(opts)
|
project, err := compose.LoadProject(ctx, opts.files, composecli.WithDefaultProfiles(opts.profiles...))
|
||||||
|
|
||||||
project, err := compose.LoadProject(ctx, opts.files, projectOpts...)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("load compose file(s): %w", err)
|
return fmt.Errorf("load compose file(s): %w", err)
|
||||||
}
|
}
|
||||||
@@ -94,21 +88,24 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
servicesToBuild := cli.GetServicesThatNeedBuild(project)
|
servicesToBuild, err := cli.ServicesThatNeedBuild(project, opts.services, false)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("determine services to build: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
if len(servicesToBuild) > 0 {
|
if len(servicesToBuild) > 0 {
|
||||||
if opts.noBuild {
|
if opts.noBuild {
|
||||||
fmt.Println("Not building services as requested.")
|
fmt.Println("Not building services as requested.")
|
||||||
} else {
|
} else {
|
||||||
buildOpts := cli.BuildOptions{
|
// Build service images without pushing them to cluster yet to not connect to the cluster twice.
|
||||||
Push: true,
|
opts.BuildServicesOptions.Deps = true // build dependencies as deploy includes them by default
|
||||||
NoCache: false,
|
opts.BuildServicesOptions.Services = opts.services
|
||||||
}
|
|
||||||
|
|
||||||
if err := cli.BuildServices(ctx, servicesToBuild, buildOpts); err != nil {
|
if err = uncli.BuildServices(ctx, project, opts.BuildServicesOptions); err != nil {
|
||||||
return fmt.Errorf("build services: %w", err)
|
return fmt.Errorf("build services: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fmt.Println()
|
||||||
}
|
}
|
||||||
|
|
||||||
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
@@ -117,6 +114,43 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
}
|
}
|
||||||
defer clusterClient.Close()
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
if len(servicesToBuild) > 0 && !opts.noBuild {
|
||||||
|
// Push built service images to cluster machines one at a time.
|
||||||
|
var errs []error
|
||||||
|
for _, s := range servicesToBuild {
|
||||||
|
if s.Image == "" {
|
||||||
|
// Skip services without an image name (shouldn't happen for services with build config).
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push to the specified x-machines or to *all* cluster machines if not specified.
|
||||||
|
var pushOpts client.PushImageOptions
|
||||||
|
if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
||||||
|
pushOpts.Machines = machines
|
||||||
|
}
|
||||||
|
if len(pushOpts.Machines) == 0 {
|
||||||
|
pushOpts.AllMachines = true
|
||||||
|
}
|
||||||
|
|
||||||
|
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
||||||
|
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
||||||
|
// Collect errors to try pushing all images.
|
||||||
|
if err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = errors.Join(errs...); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
}
|
||||||
|
|
||||||
var strategy deploy.Strategy
|
var strategy deploy.Strategy
|
||||||
if opts.recreate {
|
if opts.recreate {
|
||||||
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
strategy = &deploy.RollingStrategy{ForceRecreate: true}
|
||||||
@@ -136,7 +170,7 @@ func runDeploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Println("Deployment plan:")
|
fmt.Println(lipgloss.NewStyle().Bold(true).Render("Deployment plan"))
|
||||||
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
if err = printPlan(ctx, clusterClient, plan); err != nil {
|
||||||
return fmt.Errorf("print deployment plan: %w", err)
|
return fmt.Errorf("print deployment plan: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,312 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/lipgloss/table"
|
||||||
|
"github.com/containerd/platforms"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/go-units"
|
||||||
|
"github.com/muesli/termenv"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type listOptions struct {
|
||||||
|
machines []string
|
||||||
|
nameFilter string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewListCommand() *cobra.Command {
|
||||||
|
opts := listOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "ls [REPO:[TAG]]",
|
||||||
|
Aliases: []string{"list"},
|
||||||
|
Short: "List images on machines in the cluster.",
|
||||||
|
Long: "List images on machines in the cluster. By default, on all machines. Optionally filter by image name.",
|
||||||
|
Example: ` # List all images on all machines.
|
||||||
|
uc image ls
|
||||||
|
|
||||||
|
# List images on specific machine.
|
||||||
|
uc image ls -m machine1
|
||||||
|
|
||||||
|
# List images on multiple machines.
|
||||||
|
uc image ls -m machine1,machine2
|
||||||
|
|
||||||
|
# List images filtered by name (with any tag) on all machines.
|
||||||
|
uc image ls myapp
|
||||||
|
|
||||||
|
# List images filtered by name pattern on specific machine.
|
||||||
|
uc image ls "myapp:1.*" -m machine1`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
if len(args) > 0 {
|
||||||
|
opts.nameFilter = args[0]
|
||||||
|
}
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
return list(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Filter images by machine name or ID. Can be specified multiple times or as a comma-separated list. "+
|
||||||
|
"(default is include all machines)")
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// imageRow represents a single image with its metadata for display.
|
||||||
|
type imageRow struct {
|
||||||
|
id string
|
||||||
|
name string
|
||||||
|
platforms string
|
||||||
|
createdHuman string
|
||||||
|
createdUnix int64
|
||||||
|
size string
|
||||||
|
inUse string
|
||||||
|
store string
|
||||||
|
machine string
|
||||||
|
}
|
||||||
|
|
||||||
|
func list(ctx context.Context, uncli *cli.CLI, opts listOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
// Get all machines to create ID to name mapping.
|
||||||
|
allMachines, err := clusterClient.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineIDToName := make(map[string]string)
|
||||||
|
for _, machineMember := range allMachines {
|
||||||
|
if machineMember.Machine != nil && machineMember.Machine.Id != "" && machineMember.Machine.Name != "" {
|
||||||
|
machineIDToName[machineMember.Machine.Id] = machineMember.Machine.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
|
||||||
|
clusterImages, err := clusterClient.ListImages(ctx, api.ImageFilter{
|
||||||
|
Machines: machines,
|
||||||
|
Name: opts.nameFilter,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collect all images from all machines.
|
||||||
|
var rows []imageRow
|
||||||
|
|
||||||
|
for _, machineImages := range clusterImages {
|
||||||
|
// Get machine name for better readability.
|
||||||
|
machineName := machineImages.Metadata.Machine
|
||||||
|
if m := allMachines.FindByNameOrID(machineName); m != nil {
|
||||||
|
machineName = m.Machine.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
store := "docker"
|
||||||
|
if machineImages.ContainerdStore {
|
||||||
|
store = "containerd"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each image for this machine.
|
||||||
|
for _, img := range machineImages.Images {
|
||||||
|
// Show the first 12 chars without 'sha256:' as the image ID like Docker does.
|
||||||
|
id := strings.TrimPrefix(img.ID, "sha256:")[:12]
|
||||||
|
|
||||||
|
name := "<none>"
|
||||||
|
if len(img.RepoTags) > 0 && img.RepoTags[0] != "<none>:<none>" {
|
||||||
|
name = img.RepoTags[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
imgPlatforms, _ := imagePlatforms(img)
|
||||||
|
formattedPlatforms := formatPlatforms(imgPlatforms)
|
||||||
|
|
||||||
|
created := ""
|
||||||
|
createdAt := time.Unix(img.Created, 0)
|
||||||
|
if !createdAt.IsZero() {
|
||||||
|
created = units.HumanDuration(time.Now().UTC().Sub(createdAt)) + " ago"
|
||||||
|
}
|
||||||
|
|
||||||
|
size := units.HumanSizeWithPrecision(float64(img.Size), 3)
|
||||||
|
|
||||||
|
// Check if the image is in use by any containers. Only supported by Docker API >=1.51
|
||||||
|
inUse := "-"
|
||||||
|
if img.Containers != -1 { // -1 means the info is not available.
|
||||||
|
if img.Containers > 0 {
|
||||||
|
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("10")).Render("●")
|
||||||
|
} else {
|
||||||
|
inUse = lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render("○")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = append(rows, imageRow{
|
||||||
|
id: id,
|
||||||
|
name: name,
|
||||||
|
platforms: formattedPlatforms,
|
||||||
|
createdHuman: created,
|
||||||
|
createdUnix: img.Created,
|
||||||
|
size: size,
|
||||||
|
inUse: inUse,
|
||||||
|
store: store,
|
||||||
|
machine: machineName,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(rows) == 0 {
|
||||||
|
if opts.nameFilter != "" {
|
||||||
|
fmt.Printf("No images matching '%s' found.\n", opts.nameFilter)
|
||||||
|
} else {
|
||||||
|
fmt.Println("No images found.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort images by name, then by machine name.
|
||||||
|
sort.Slice(rows, func(i, j int) bool {
|
||||||
|
if rows[i].name != rows[j].name {
|
||||||
|
return rows[i].name < rows[j].name
|
||||||
|
}
|
||||||
|
return rows[i].machine < rows[j].machine
|
||||||
|
})
|
||||||
|
|
||||||
|
// Print the images in a table format.
|
||||||
|
fmt.Println(formatImageTable(rows))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// imagePlatforms returns a list of platforms supported by the image and a boolean indicating if it's multi-platform.
|
||||||
|
func imagePlatforms(img image.Summary) ([]string, bool) {
|
||||||
|
var formattedPlatforms []string
|
||||||
|
multiPlatform := false
|
||||||
|
|
||||||
|
for _, m := range img.Manifests {
|
||||||
|
if m.Kind != image.ManifestKindImage || !m.Available {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.ID != img.ID {
|
||||||
|
// There is an image manifest that has digest different from the main image digest.
|
||||||
|
// This means the image manifest is an index or a manifest list (multi-platform image).
|
||||||
|
multiPlatform = true
|
||||||
|
}
|
||||||
|
formattedPlatforms = append(formattedPlatforms, platforms.Format(m.ImageData.Platform))
|
||||||
|
}
|
||||||
|
|
||||||
|
slices.Sort(formattedPlatforms)
|
||||||
|
|
||||||
|
return formattedPlatforms, multiPlatform
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatPlatforms(platforms []string) string {
|
||||||
|
if len(platforms) == 0 {
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
platformStyle := lipgloss.NewStyle().
|
||||||
|
BorderForeground(lipgloss.Color("152")).
|
||||||
|
Foreground(lipgloss.Color("0")).
|
||||||
|
Background(lipgloss.Color("152"))
|
||||||
|
// Use fancy pill borders only if the output is a terminal with color support.
|
||||||
|
if lipgloss.ColorProfile() != termenv.Ascii {
|
||||||
|
platformStyle = platformStyle.Border(lipgloss.Border{Left: "", Right: ""}, false, true, false, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
styledPlatforms := make([]string, len(platforms))
|
||||||
|
for i, p := range platforms {
|
||||||
|
styledPlatforms[i] = platformStyle.Render(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(styledPlatforms, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatImageTable(rows []imageRow) string {
|
||||||
|
columns := []struct {
|
||||||
|
name string
|
||||||
|
hide bool
|
||||||
|
}{
|
||||||
|
{name: "IMAGE ID"},
|
||||||
|
{name: "NAME"},
|
||||||
|
{name: "PLATFORMS"},
|
||||||
|
{name: "CREATED"},
|
||||||
|
{name: "SIZE"},
|
||||||
|
{name: "IN USE"},
|
||||||
|
{name: "STORE"},
|
||||||
|
{name: "MACHINE"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide the "IN USE" column if none of the images have that info available.
|
||||||
|
inUseInfoAvailable := slices.ContainsFunc(rows, func(r imageRow) bool {
|
||||||
|
return r.inUse != "-"
|
||||||
|
})
|
||||||
|
if !inUseInfoAvailable {
|
||||||
|
// Hide "IN USE" column.
|
||||||
|
columns[5].hide = true
|
||||||
|
}
|
||||||
|
|
||||||
|
t := table.New().
|
||||||
|
// Remove the default border.
|
||||||
|
Border(lipgloss.Border{}).
|
||||||
|
BorderTop(false).
|
||||||
|
BorderBottom(false).
|
||||||
|
BorderLeft(false).
|
||||||
|
BorderRight(false).
|
||||||
|
BorderHeader(false).
|
||||||
|
BorderColumn(false).
|
||||||
|
StyleFunc(func(row, col int) lipgloss.Style {
|
||||||
|
if row == table.HeaderRow {
|
||||||
|
return lipgloss.NewStyle().Bold(true).PaddingRight(3)
|
||||||
|
}
|
||||||
|
// Regular style for data rows with padding.
|
||||||
|
return lipgloss.NewStyle().PaddingRight(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
var headers []string
|
||||||
|
for _, col := range columns {
|
||||||
|
if !col.hide {
|
||||||
|
headers = append(headers, col.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Headers(headers...)
|
||||||
|
|
||||||
|
for _, row := range rows {
|
||||||
|
values := []string{
|
||||||
|
row.id,
|
||||||
|
row.name,
|
||||||
|
row.platforms,
|
||||||
|
row.createdHuman,
|
||||||
|
row.size,
|
||||||
|
row.inUse,
|
||||||
|
row.store,
|
||||||
|
row.machine,
|
||||||
|
}
|
||||||
|
var filteredValues []string
|
||||||
|
for i, v := range values {
|
||||||
|
if !columns[i].hide {
|
||||||
|
filteredValues = append(filteredValues, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Row(filteredValues...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/containerd/platforms"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type pushOptions struct {
|
||||||
|
image string
|
||||||
|
machines []string
|
||||||
|
platform string
|
||||||
|
context string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPushCommand() *cobra.Command {
|
||||||
|
opts := pushOptions{}
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "push IMAGE",
|
||||||
|
Short: "Upload a local Docker image to the cluster.",
|
||||||
|
Long: `Upload a local Docker image to the cluster transferring only the missing layers.
|
||||||
|
The image is uploaded to all cluster machines (default) or the specified machine(s).`,
|
||||||
|
Example: ` # Push image to all machines in the cluster.
|
||||||
|
uc image push myapp:latest
|
||||||
|
|
||||||
|
# Push image to specific machine.
|
||||||
|
uc image push myapp:latest -m machine1
|
||||||
|
|
||||||
|
# Push image to multiple machines.
|
||||||
|
uc image push myapp:latest -m machine1,machine2,machine3
|
||||||
|
|
||||||
|
# Push a specific platform of a multi-platform image.
|
||||||
|
uc image push myapp:latest --platform linux/amd64`,
|
||||||
|
Args: cobra.ExactArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
|
||||||
|
opts.image = args[0]
|
||||||
|
return push(cmd.Context(), uncli, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringSliceVarP(&opts.machines, "machine", "m", nil,
|
||||||
|
"Machine names or IDs to push the image to. Can be specified multiple times or as a comma-separated list. "+
|
||||||
|
"(default is all machines)")
|
||||||
|
cmd.Flags().StringVar(
|
||||||
|
&opts.platform, "platform", "",
|
||||||
|
"Push a specific platform of a multi-platform image (e.g., linux/amd64, linux/arm64).\n"+
|
||||||
|
"Local Docker must be configured to use containerd image store to support multi-platform images.",
|
||||||
|
)
|
||||||
|
cmd.Flags().StringVarP(
|
||||||
|
&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)",
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func push(ctx context.Context, uncli *cli.CLI, opts pushOptions) error {
|
||||||
|
clusterClient, err := uncli.ConnectCluster(ctx, opts.context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
machines := cli.ExpandCommaSeparatedValues(opts.machines)
|
||||||
|
pushOpts := client.PushImageOptions{}
|
||||||
|
|
||||||
|
// Special handling for an explicit "all" keyword to push to all machines.
|
||||||
|
if len(machines) == 1 && machines[0] == "all" {
|
||||||
|
pushOpts.AllMachines = true
|
||||||
|
} else if len(machines) > 0 {
|
||||||
|
pushOpts.Machines = machines
|
||||||
|
} else {
|
||||||
|
// Default is to push to all machines in the cluster.
|
||||||
|
pushOpts.AllMachines = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if opts.platform != "" {
|
||||||
|
p, err := platforms.Parse(opts.platform)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid platform '%s': %w", opts.platform, err)
|
||||||
|
}
|
||||||
|
pushOpts.Platform = &p
|
||||||
|
}
|
||||||
|
|
||||||
|
return progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
|
if err = clusterClient.PushImage(ctx, opts.image, pushOpts); err != nil {
|
||||||
|
return fmt.Errorf("push image to cluster: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, uncli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", opts.image))
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package image
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewRootCommand() *cobra.Command {
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "image",
|
||||||
|
Short: "Manage images on machines in the cluster.",
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.AddCommand(
|
||||||
|
NewListCommand(),
|
||||||
|
NewPushCommand(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewImagesCommand returns the 'image ls' command modified to work as 'images'.
|
||||||
|
func NewImagesCommand() *cobra.Command {
|
||||||
|
listCmd := image.NewListCommand()
|
||||||
|
listCmd.Use = "images [IMAGE]"
|
||||||
|
// Remove 'list' alias since this command is already an alias.
|
||||||
|
listCmd.Aliases = nil
|
||||||
|
listCmd.Example = strings.ReplaceAll(listCmd.Example, "uc image ls", "uc images")
|
||||||
|
|
||||||
|
return listCmd
|
||||||
|
}
|
||||||
@@ -46,7 +46,7 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
// Print the list of machines in a table format.
|
// Print the list of machines in a table format.
|
||||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0)
|
||||||
// Print header.
|
// Print header.
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tSTATE\tADDRESS\tPUBLIC IP\tWIREGUARD ENDPOINTS\tMACHINE ID"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
// Print rows.
|
// Print rows.
|
||||||
@@ -68,8 +68,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if _, err = fmt.Fprintf(
|
if _, err = fmt.Fprintf(
|
||||||
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
tw, "%s\t%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
|
||||||
strings.Join(endpoints, ", "),
|
strings.Join(endpoints, ", "), member.Machine.Id,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-1
@@ -9,12 +9,14 @@ import (
|
|||||||
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
"github.com/psviderski/uncloud/cmd/uncloud/caddy"
|
||||||
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
cmdcontext "github.com/psviderski/uncloud/cmd/uncloud/context"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
"github.com/psviderski/uncloud/cmd/uncloud/dns"
|
||||||
|
"github.com/psviderski/uncloud/cmd/uncloud/image"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
"github.com/psviderski/uncloud/cmd/uncloud/machine"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
"github.com/psviderski/uncloud/cmd/uncloud/service"
|
||||||
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
"github.com/psviderski/uncloud/cmd/uncloud/volume"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
"github.com/psviderski/uncloud/internal/cli/config"
|
"github.com/psviderski/uncloud/internal/cli/config"
|
||||||
"github.com/psviderski/uncloud/internal/fs"
|
"github.com/psviderski/uncloud/internal/fs"
|
||||||
|
"github.com/psviderski/uncloud/internal/log"
|
||||||
"github.com/psviderski/uncloud/internal/version"
|
"github.com/psviderski/uncloud/internal/version"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
@@ -25,6 +27,8 @@ type globalOptions struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
log.InitLoggerFromEnv()
|
||||||
|
|
||||||
opts := globalOptions{}
|
opts := globalOptions{}
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "uc",
|
Use: "uc",
|
||||||
@@ -46,6 +50,11 @@ func main() {
|
|||||||
conn = &config.MachineConnection{
|
conn = &config.MachineConnection{
|
||||||
TCP: &addrPort,
|
TCP: &addrPort,
|
||||||
}
|
}
|
||||||
|
} else if strings.HasPrefix(opts.connect, "ssh+cli://") {
|
||||||
|
dest := opts.connect[len("ssh+cli://"):]
|
||||||
|
conn = &config.MachineConnection{
|
||||||
|
SSHCLI: config.SSHDestination(dest),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
dest := opts.connect
|
dest := opts.connect
|
||||||
if strings.HasPrefix(dest, "ssh://") {
|
if strings.HasPrefix(dest, "ssh://") {
|
||||||
@@ -69,7 +78,7 @@ func main() {
|
|||||||
|
|
||||||
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
cmd.PersistentFlags().StringVar(&opts.connect, "connect", "",
|
||||||
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
"Connect to a remote cluster machine without using the Uncloud configuration file. [$UNCLOUD_CONNECT]\n"+
|
||||||
"Format: [ssh://]user@host[:port] or tcp://host:port")
|
"Format: [ssh://]user@host[:port], ssh+cli://user@host[:port], or tcp://host:port")
|
||||||
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
cmd.PersistentFlags().StringVar(&opts.configPath, "uncloud-config", "~/.config/uncloud/config.yaml",
|
||||||
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
"Path to the Uncloud configuration file. [$UNCLOUD_CONFIG]")
|
||||||
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
_ = cmd.MarkPersistentFlagFilename("uncloud-config", "yaml", "yml")
|
||||||
@@ -79,11 +88,14 @@ func main() {
|
|||||||
NewDeployCommand(),
|
NewDeployCommand(),
|
||||||
NewDocsCommand(),
|
NewDocsCommand(),
|
||||||
NewBuildCommand(),
|
NewBuildCommand(),
|
||||||
|
NewImagesCommand(),
|
||||||
caddy.NewRootCommand(),
|
caddy.NewRootCommand(),
|
||||||
cmdcontext.NewRootCommand(),
|
cmdcontext.NewRootCommand(),
|
||||||
dns.NewRootCommand(),
|
dns.NewRootCommand(),
|
||||||
|
image.NewRootCommand(),
|
||||||
machine.NewRootCommand(),
|
machine.NewRootCommand(),
|
||||||
service.NewRootCommand(),
|
service.NewRootCommand(),
|
||||||
|
service.NewExecCommand(),
|
||||||
service.NewInspectCommand(),
|
service.NewInspectCommand(),
|
||||||
service.NewListCommand(),
|
service.NewListCommand(),
|
||||||
service.NewRmCommand(),
|
service.NewRmCommand(),
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/streams"
|
||||||
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
type execCliOptions struct {
|
||||||
|
detach bool
|
||||||
|
interactive bool
|
||||||
|
noTty bool
|
||||||
|
context string
|
||||||
|
containerId string
|
||||||
|
}
|
||||||
|
|
||||||
|
var DEFAULT_COMMAND = []string{"sh", "-c", "command -v bash >/dev/null 2>&1 && exec bash || exec sh"}
|
||||||
|
|
||||||
|
func NewExecCommand() *cobra.Command {
|
||||||
|
opts := execCliOptions{}
|
||||||
|
|
||||||
|
execCmd := &cobra.Command{
|
||||||
|
Use: "exec [OPTIONS] SERVICE [COMMAND ARGS...]",
|
||||||
|
Short: "Execute a command in a running service container",
|
||||||
|
Long: `Execute a command (interactive shell by default) in a running container within a service.
|
||||||
|
If the service has multiple replicas and no container ID is specified, the command will be executed in a random container.
|
||||||
|
`,
|
||||||
|
Example: `
|
||||||
|
# Start an interactive shell ("bash" or "sh" will be tried by default)
|
||||||
|
uc exec web-service
|
||||||
|
|
||||||
|
# Start an interactive shell with explicit command
|
||||||
|
uc exec web-service /bin/zsh
|
||||||
|
|
||||||
|
# List files in the specific container of the service; --container accepts full ID or a (unique) prefix
|
||||||
|
uc exec --container d792e web-service ls -la
|
||||||
|
|
||||||
|
# Pipe input to a command inside the service container
|
||||||
|
cat backup.sql | uc exec -T db-service psql -U postgres mydb
|
||||||
|
|
||||||
|
# Run a task in the background (detached mode)
|
||||||
|
uc exec -d web-service /scripts/cleanup.sh`,
|
||||||
|
Args: cobra.MinimumNArgs(1),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
uncli := cmd.Context().Value("cli").(*cli.CLI)
|
||||||
|
serviceName := args[0]
|
||||||
|
command := args[1:]
|
||||||
|
if len(command) == 0 {
|
||||||
|
command = DEFAULT_COMMAND
|
||||||
|
}
|
||||||
|
return runExec(cmd.Context(), uncli, serviceName, command, opts)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
execCmd.Flags().BoolVarP(&opts.detach, "detach", "d", false, "Detached mode: run command in the background")
|
||||||
|
|
||||||
|
execCmd.Flags().BoolVarP(&opts.noTty, "no-tty", "T", !cli.IsStdoutTerminal(),
|
||||||
|
"Disable pseudo-TTY allocation. By default 'uc exec' allocates a TTY when connected to a terminal.")
|
||||||
|
|
||||||
|
// Keep "-i" and "-t" flags hidden for compatibility with docker exec
|
||||||
|
execCmd.Flags().BoolVarP(&opts.interactive, "interactive", "i", true, "Keep STDIN open even if not attached")
|
||||||
|
execCmd.Flags().MarkHidden("interactive")
|
||||||
|
|
||||||
|
execCmd.Flags().BoolP("tty", "t", false, "Allocate a pseudo-TTY")
|
||||||
|
execCmd.Flags().MarkHidden("tty")
|
||||||
|
|
||||||
|
execCmd.Flags().StringVarP(&opts.context, "context", "c", "",
|
||||||
|
"Name of the cluster context. (default is the current context)")
|
||||||
|
|
||||||
|
// Common flags
|
||||||
|
execCmd.Flags().StringVar(&opts.containerId, "container", "",
|
||||||
|
"ID of the container to exec into. Accepts full ID or a unique prefix "+
|
||||||
|
"(default is the random container of the service)")
|
||||||
|
|
||||||
|
// This tells Cobra that all flags must come before positional arguments, so that
|
||||||
|
// commands with their own flags can be handled correctly.
|
||||||
|
execCmd.Flags().SetInterspersed(false)
|
||||||
|
|
||||||
|
return execCmd
|
||||||
|
}
|
||||||
|
|
||||||
|
func runExec(ctx context.Context, uncli *cli.CLI, serviceName string, command []string, opts execCliOptions) error {
|
||||||
|
if !opts.detach {
|
||||||
|
// Check if we're trying to attach to a TTY from a non-TTY client, e.g.
|
||||||
|
// when doing an 'cmd | uc exec ...'
|
||||||
|
stdin := streams.NewIn(os.Stdin)
|
||||||
|
// TODO: this logic/behavior mirrors docker-compose, but we can be smarter about it and detect TTY dynamically
|
||||||
|
if err := stdin.CheckTty(opts.interactive, !opts.noTty); err != nil {
|
||||||
|
return fmt.Errorf("check TTY: %w; use -T option to disable TTY allocation", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := uncli.ConnectClusterWithOptions(ctx, opts.context, cli.ConnectOptions{
|
||||||
|
ShowProgress: false,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer client.Close()
|
||||||
|
|
||||||
|
execConfig := api.ExecOptions{
|
||||||
|
Command: command,
|
||||||
|
AttachStdin: opts.interactive,
|
||||||
|
Tty: !opts.noTty,
|
||||||
|
Detach: opts.detach,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.detach {
|
||||||
|
execConfig.AttachStdout = true
|
||||||
|
execConfig.AttachStderr = true
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := client.ExecContainer(ctx, serviceName, opts.containerId, execConfig)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("exec container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// For non-detached mode, exit with the same code as the executed command
|
||||||
|
if !opts.detach {
|
||||||
|
if exitCode != 0 {
|
||||||
|
os.Exit(exitCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -58,9 +58,9 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
|
|||||||
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
machinesNamesByID[m.Machine.Id] = m.Machine.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("ID: %s\n", svc.ID)
|
fmt.Printf("Service ID: %s\n", svc.ID)
|
||||||
fmt.Printf("Name: %s\n", svc.Name)
|
fmt.Printf("Name: %s\n", svc.Name)
|
||||||
fmt.Printf("Mode: %s\n", svc.Mode)
|
fmt.Printf("Mode: %s\n", svc.Mode)
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
|
|
||||||
// Print the list of containers in a table format.
|
// Print the list of containers in a table format.
|
||||||
|
|||||||
@@ -60,19 +60,29 @@ func list(ctx context.Context, uncli *cli.CLI, contextName string) error {
|
|||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tENDPOINTS"); err != nil {
|
if _, err = fmt.Fprintln(tw, "NAME\tMODE\tREPLICAS\tIMAGE\tENDPOINTS"); err != nil {
|
||||||
return fmt.Errorf("write header: %w", err)
|
return fmt.Errorf("write header: %w", err)
|
||||||
}
|
}
|
||||||
for _, s := range services {
|
for _, s := range services {
|
||||||
endpointsSlice := s.Endpoints()
|
images := strings.Join(s.Images(), ", ")
|
||||||
endpoints := strings.Join(endpointsSlice, ", ")
|
endpoints := strings.Join(s.Endpoints(), ", ")
|
||||||
|
|
||||||
|
// If no endpoints from ports, check if the service uses custom Caddy config.
|
||||||
|
if endpoints == "" {
|
||||||
|
for _, ctr := range s.Containers {
|
||||||
|
if ctr.Container.ServiceSpec.CaddyConfig() != "" {
|
||||||
|
endpoints = "(custom Caddy config)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if haveDuplicateNames {
|
if haveDuplicateNames {
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t", s.ID); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\n", s.Name, s.Mode, len(s.Containers), endpoints); err != nil {
|
if _, err = fmt.Fprintf(tw, "%s\t%s\t%d\t%s\t%s\n",
|
||||||
|
s.Name, s.Mode, len(s.Containers), images, endpoints); err != nil {
|
||||||
return fmt.Errorf("write row: %w", err)
|
return fmt.Errorf("write row: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,5 +50,5 @@ func rm(ctx context.Context, uncli *cli.CLI, opts rmOptions) error {
|
|||||||
}, uncli.ProgressOut(), "Removing service "+s)
|
}, uncli.ProgressOut(), "Removing service "+s)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ func NewRootCommand() *cobra.Command {
|
|||||||
NewRmCommand(),
|
NewRmCommand(),
|
||||||
NewRunCommand(),
|
NewRunCommand(),
|
||||||
NewScaleCommand(),
|
NewScaleCommand(),
|
||||||
|
NewExecCommand(),
|
||||||
)
|
)
|
||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newDialStdioCommand() *cobra.Command {
|
||||||
|
var socketPath string
|
||||||
|
|
||||||
|
cmd := &cobra.Command{
|
||||||
|
Use: "dial-stdio",
|
||||||
|
Short: "Proxy stdin/stdout to the Uncloud API socket",
|
||||||
|
Hidden: true,
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
return runDialStdio(cmd.Context(), socketPath, os.Stdin, os.Stdout)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Flags().StringVar(&socketPath, "socket", machine.DefaultUncloudSockPath,
|
||||||
|
"Path to the Uncloud API socket")
|
||||||
|
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfReadCloser is the read side of a half-duplex connection.
|
||||||
|
type halfReadCloser interface {
|
||||||
|
io.Reader
|
||||||
|
CloseRead() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfWriteCloser is the write side of a half-duplex connection.
|
||||||
|
type halfWriteCloser interface {
|
||||||
|
io.Writer
|
||||||
|
CloseWrite() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfReadCloserWrapper wraps an io.ReadCloser to implement halfReadCloser.
|
||||||
|
type halfReadCloserWrapper struct {
|
||||||
|
io.ReadCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *halfReadCloserWrapper) CloseRead() error {
|
||||||
|
return x.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfWriteCloserWrapper wraps an io.WriteCloser to implement halfWriteCloser.
|
||||||
|
type halfWriteCloserWrapper struct {
|
||||||
|
io.WriteCloser
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *halfWriteCloserWrapper) CloseWrite() error {
|
||||||
|
return x.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
func runDialStdio(ctx context.Context, socketPath string, stdin io.Reader, stdout io.Writer) error {
|
||||||
|
// Connect to the unix socket.
|
||||||
|
var dialer net.Dialer
|
||||||
|
conn, err := dialer.DialContext(ctx, "unix", socketPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to socket %q: %w", socketPath, err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
// Wrap stdin/stdout to support half-closing.
|
||||||
|
var stdinCloser halfReadCloser
|
||||||
|
if c, ok := stdin.(halfReadCloser); ok {
|
||||||
|
stdinCloser = c
|
||||||
|
} else if c, ok := stdin.(io.ReadCloser); ok {
|
||||||
|
stdinCloser = &halfReadCloserWrapper{c}
|
||||||
|
}
|
||||||
|
|
||||||
|
var stdoutCloser halfWriteCloser
|
||||||
|
if c, ok := stdout.(halfWriteCloser); ok {
|
||||||
|
stdoutCloser = c
|
||||||
|
} else if c, ok := stdout.(io.WriteCloser); ok {
|
||||||
|
stdoutCloser = &halfWriteCloserWrapper{c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy data bidirectionally between stdin/stdout and the socket.
|
||||||
|
stdin2socket := make(chan error, 1)
|
||||||
|
socket2stdout := make(chan error, 1)
|
||||||
|
|
||||||
|
// Copy from stdin to socket.
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(conn, stdin)
|
||||||
|
stdin2socket <- err
|
||||||
|
// Close write side of connection after stdin is done.
|
||||||
|
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||||
|
unixConn.CloseWrite()
|
||||||
|
}
|
||||||
|
if stdinCloser != nil {
|
||||||
|
stdinCloser.CloseRead()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Copy from socket to stdout.
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(stdout, conn)
|
||||||
|
socket2stdout <- err
|
||||||
|
// Close read side of connection after socket is done sending.
|
||||||
|
if unixConn, ok := conn.(*net.UnixConn); ok {
|
||||||
|
unixConn.CloseRead()
|
||||||
|
}
|
||||||
|
if stdoutCloser != nil {
|
||||||
|
stdoutCloser.CloseWrite()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err = <-stdin2socket:
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// wait for stdout
|
||||||
|
err = <-socket2stdout
|
||||||
|
case err = <-socket2stdout:
|
||||||
|
// return immediately, matching Docker's approach
|
||||||
|
// (stdin is never closed when TTY)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -42,6 +42,9 @@ func main() {
|
|||||||
"Directory for storing persistent machine state")
|
"Directory for storing persistent machine state")
|
||||||
_ = cmd.MarkFlagDirname("data-dir")
|
_ = cmd.MarkFlagDirname("data-dir")
|
||||||
|
|
||||||
|
// Add dial-stdio subcommand.
|
||||||
|
cmd.AddCommand(newDialStdioCommand())
|
||||||
|
|
||||||
// ctx is canceled when the daemon command is interrupted.
|
// ctx is canceled when the daemon command is interrupted.
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ module github.com/psviderski/uncloud
|
|||||||
go 1.25
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/BurntSushi/toml v1.4.0
|
github.com/BurntSushi/toml v1.5.0
|
||||||
github.com/Masterminds/semver v1.5.0
|
github.com/Masterminds/semver v1.5.0
|
||||||
github.com/Masterminds/squirrel v1.5.4
|
github.com/Masterminds/squirrel v1.5.4
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0
|
github.com/alecthomas/chroma/v2 v2.20.0
|
||||||
@@ -13,15 +13,17 @@ require (
|
|||||||
github.com/charmbracelet/bubbletea v1.3.9
|
github.com/charmbracelet/bubbletea v1.3.9
|
||||||
github.com/charmbracelet/huh v0.6.0
|
github.com/charmbracelet/huh v0.6.0
|
||||||
github.com/charmbracelet/lipgloss v1.1.0
|
github.com/charmbracelet/lipgloss v1.1.0
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5
|
github.com/compose-spec/compose-go/v2 v2.9.0
|
||||||
|
github.com/containerd/errdefs v1.0.0
|
||||||
|
github.com/containerd/platforms v1.0.0-rc.1
|
||||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0
|
github.com/deckarep/golang-set/v2 v2.8.0
|
||||||
github.com/dgraph-io/badger/v3 v3.2103.5
|
github.com/dgraph-io/badger/v3 v3.2103.5
|
||||||
github.com/distribution/reference v0.6.0
|
github.com/distribution/reference v0.6.0
|
||||||
github.com/docker/cli v27.5.0+incompatible
|
github.com/docker/cli v28.5.0+incompatible
|
||||||
github.com/docker/compose/v2 v2.31.0
|
github.com/docker/compose/v2 v2.40.0
|
||||||
github.com/docker/docker v27.4.0-rc.2+incompatible
|
github.com/docker/docker v28.5.0+incompatible
|
||||||
github.com/docker/go-connections v0.5.0
|
github.com/docker/go-connections v0.6.0
|
||||||
github.com/docker/go-units v0.5.0
|
github.com/docker/go-units v0.5.0
|
||||||
github.com/goccy/go-yaml v1.17.1
|
github.com/goccy/go-yaml v1.17.1
|
||||||
github.com/google/go-cmp v0.7.0
|
github.com/google/go-cmp v0.7.0
|
||||||
@@ -39,47 +41,66 @@ require (
|
|||||||
github.com/lmittmann/tint v1.0.5
|
github.com/lmittmann/tint v1.0.5
|
||||||
github.com/miekg/dns v1.1.65
|
github.com/miekg/dns v1.1.65
|
||||||
github.com/mitchellh/mapstructure v1.5.0
|
github.com/mitchellh/mapstructure v1.5.0
|
||||||
github.com/moby/term v0.5.0
|
github.com/moby/term v0.5.2
|
||||||
|
github.com/muesli/termenv v0.16.0
|
||||||
github.com/opencontainers/go-digest v1.0.0
|
github.com/opencontainers/go-digest v1.0.0
|
||||||
github.com/opencontainers/image-spec v1.1.1
|
github.com/opencontainers/image-spec v1.1.1
|
||||||
github.com/psviderski/unregistry v0.3.1
|
github.com/psviderski/unregistry v0.3.1
|
||||||
github.com/siderolabs/discovery-api v0.1.4
|
github.com/siderolabs/discovery-api v0.1.4
|
||||||
github.com/siderolabs/discovery-client v0.1.9
|
github.com/siderolabs/discovery-client v0.1.9
|
||||||
github.com/siderolabs/grpc-proxy v0.5.1
|
github.com/siderolabs/grpc-proxy v0.5.1
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.10.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.11.1
|
||||||
github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8
|
github.com/vishvananda/netlink v1.3.1
|
||||||
go.uber.org/zap v1.27.0
|
go.uber.org/zap v1.27.0
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.41.0
|
||||||
golang.org/x/net v0.38.0
|
golang.org/x/net v0.43.0
|
||||||
golang.org/x/sync v0.14.0
|
golang.org/x/sync v0.17.0
|
||||||
golang.org/x/sys v0.36.0
|
golang.org/x/sys v0.36.0
|
||||||
golang.org/x/term v0.30.0
|
golang.org/x/term v0.34.0
|
||||||
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
|
||||||
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
golang.zx2c4.com/wireguard/wgctrl v0.0.0-20230429144221-925a1e7659e6
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a
|
||||||
google.golang.org/grpc v1.72.0
|
google.golang.org/grpc v1.74.2
|
||||||
google.golang.org/protobuf v1.36.6
|
google.golang.org/protobuf v1.36.9
|
||||||
modernc.org/sqlite v1.36.3
|
modernc.org/sqlite v1.36.3
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
cloud.google.com/go/kms v1.19.0 // indirect
|
||||||
|
cloud.google.com/go/longrunning v0.6.0 // indirect
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
|
github.com/AlecAivazis/survey/v2 v2.3.7 // indirect
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 // indirect
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
|
||||||
|
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e // indirect
|
||||||
github.com/Masterminds/goutils v1.1.1 // indirect
|
github.com/Masterminds/goutils v1.1.1 // indirect
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 // indirect
|
github.com/Masterminds/semver/v3 v3.4.0 // indirect
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
github.com/Masterminds/sprig/v3 v3.2.3 // indirect
|
||||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||||
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
github.com/Microsoft/hcsshim v0.13.0 // indirect
|
||||||
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d // indirect
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 // indirect
|
||||||
github.com/armon/go-metrics v0.4.1 // indirect
|
github.com/armon/go-metrics v0.4.1 // indirect
|
||||||
github.com/armon/go-radix v1.0.0 // indirect
|
github.com/armon/go-radix v1.0.0 // indirect
|
||||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b // indirect
|
||||||
github.com/atotto/clipboard v0.1.4 // indirect
|
github.com/atotto/clipboard v0.1.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.30.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.27.27 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 // indirect
|
||||||
|
github.com/aws/smithy-go v1.20.3 // indirect
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bgentry/speakeasy v0.2.0 // indirect
|
github.com/bgentry/speakeasy v0.2.0 // indirect
|
||||||
@@ -95,24 +116,20 @@ require (
|
|||||||
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
github.com/charmbracelet/x/exp/strings v0.0.0-20240919170804-a4978c8e603a // indirect
|
||||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||||
github.com/chzyer/readline v1.5.1 // indirect
|
github.com/chzyer/readline v1.5.1 // indirect
|
||||||
github.com/cloudflare/cfssl v1.6.4 // indirect
|
|
||||||
github.com/containerd/cgroups/v3 v3.0.5 // indirect
|
github.com/containerd/cgroups/v3 v3.0.5 // indirect
|
||||||
github.com/containerd/console v1.0.4 // indirect
|
github.com/containerd/console v1.0.5 // indirect
|
||||||
github.com/containerd/containerd v1.7.24 // indirect
|
|
||||||
github.com/containerd/containerd/api v1.9.0 // indirect
|
github.com/containerd/containerd/api v1.9.0 // indirect
|
||||||
github.com/containerd/containerd/v2 v2.1.1 // indirect
|
github.com/containerd/containerd/v2 v2.1.4 // indirect
|
||||||
github.com/containerd/continuity v0.4.5 // indirect
|
github.com/containerd/continuity v0.4.5 // indirect
|
||||||
github.com/containerd/errdefs v1.0.0 // indirect
|
|
||||||
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
github.com/containerd/errdefs/pkg v0.3.0 // indirect
|
||||||
github.com/containerd/fifo v1.1.0 // indirect
|
github.com/containerd/fifo v1.1.0 // indirect
|
||||||
github.com/containerd/log v0.1.0 // indirect
|
github.com/containerd/log v0.1.0 // indirect
|
||||||
github.com/containerd/platforms v1.0.0-rc.1 // indirect
|
|
||||||
github.com/containerd/plugin v1.0.0 // indirect
|
github.com/containerd/plugin v1.0.0 // indirect
|
||||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||||
github.com/containerd/ttrpc v1.2.7 // indirect
|
github.com/containerd/ttrpc v1.2.7 // indirect
|
||||||
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
github.com/containerd/typeurl/v2 v2.2.3 // indirect
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.3.0 // indirect
|
||||||
github.com/dgraph-io/badger v1.6.2 // indirect
|
github.com/dgraph-io/badger v1.6.2 // indirect
|
||||||
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
github.com/dgraph-io/badger/v2 v2.2007.4 // indirect
|
||||||
@@ -121,44 +138,58 @@ require (
|
|||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/distribution/distribution/v3 v3.0.0 // indirect
|
github.com/distribution/distribution/v3 v3.0.0 // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/docker/buildx v0.18.0 // indirect
|
github.com/docker/buildx v0.29.1 // indirect
|
||||||
|
github.com/docker/cli-docs-tool v0.10.0 // indirect
|
||||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
|
||||||
github.com/docker/go-metrics v0.0.1 // indirect
|
github.com/docker/go-metrics v0.0.1 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0 // indirect
|
||||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||||
github.com/fatih/color v1.17.0 // indirect
|
github.com/fatih/color v1.17.0 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/fsnotify/fsevents v0.2.0 // indirect
|
||||||
github.com/fvbommel/sortorder v1.1.0 // indirect
|
github.com/fvbommel/sortorder v1.1.0 // indirect
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
github.com/go-jose/go-jose/v3 v3.0.3 // indirect
|
||||||
github.com/go-kit/kit v0.13.0 // indirect
|
github.com/go-kit/kit v0.13.0 // indirect
|
||||||
github.com/go-kit/log v0.2.1 // indirect
|
github.com/go-kit/log v0.2.1 // indirect
|
||||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 // indirect
|
||||||
|
github.com/go-openapi/swag v0.23.0 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 // indirect
|
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||||
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
github.com/godbus/dbus/v5 v5.1.0 // indirect
|
||||||
github.com/gofrs/flock v0.12.1 // indirect
|
github.com/gofrs/flock v0.12.1 // indirect
|
||||||
github.com/gogo/protobuf v1.3.2 // indirect
|
github.com/gogo/protobuf v1.3.2 // indirect
|
||||||
github.com/golang/glog v1.2.4 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
|
github.com/golang/glog v1.2.5 // indirect
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v0.0.4 // indirect
|
github.com/golang/snappy v0.0.4 // indirect
|
||||||
github.com/google/btree v1.1.2 // indirect
|
github.com/google/btree v1.1.2 // indirect
|
||||||
github.com/google/cel-go v0.20.1 // indirect
|
github.com/google/cel-go v0.20.1 // indirect
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
github.com/google/flatbuffers v24.3.25+incompatible // indirect
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect
|
github.com/google/gnostic-models v0.6.8 // indirect
|
||||||
|
github.com/google/gofuzz v1.2.0 // indirect
|
||||||
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
|
||||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
|
||||||
github.com/google/uuid v1.6.0 // indirect
|
github.com/google/uuid v1.6.0 // indirect
|
||||||
github.com/gorilla/handlers v1.5.2 // indirect
|
github.com/gorilla/handlers v1.5.2 // indirect
|
||||||
github.com/gorilla/mux v1.8.1 // indirect
|
github.com/gorilla/mux v1.8.1 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 // indirect
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
github.com/hashicorp/go-msgpack v0.5.5 // indirect
|
||||||
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
github.com/hashicorp/go-msgpack/v2 v2.1.2 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
@@ -172,8 +203,9 @@ require (
|
|||||||
github.com/hashicorp/mdns v1.0.5 // indirect
|
github.com/hashicorp/mdns v1.0.5 // indirect
|
||||||
github.com/huandu/xstrings v1.5.0 // indirect
|
github.com/huandu/xstrings v1.5.0 // indirect
|
||||||
github.com/imdario/mergo v0.3.16 // indirect
|
github.com/imdario/mergo v0.3.16 // indirect
|
||||||
github.com/in-toto/in-toto-golang v0.5.0 // indirect
|
github.com/in-toto/in-toto-golang v0.9.0 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
|
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf // indirect
|
||||||
github.com/ipfs/bbloom v0.0.4 // indirect
|
github.com/ipfs/bbloom v0.0.4 // indirect
|
||||||
github.com/ipfs/go-block-format v0.2.0 // indirect
|
github.com/ipfs/go-block-format v0.2.0 // indirect
|
||||||
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
github.com/ipfs/go-ipfs-util v0.0.3 // indirect
|
||||||
@@ -191,7 +223,11 @@ require (
|
|||||||
github.com/jackc/pgtype v1.14.0 // indirect
|
github.com/jackc/pgtype v1.14.0 // indirect
|
||||||
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
github.com/jackc/pgx/v4 v4.18.3 // indirect
|
||||||
github.com/jbenet/goprocess v0.1.4 // indirect
|
github.com/jbenet/goprocess v0.1.4 // indirect
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
|
github.com/josharian/intern v1.0.0 // indirect
|
||||||
github.com/josharian/native v1.1.0 // indirect
|
github.com/josharian/native v1.1.0 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
|
||||||
github.com/klauspost/compress v1.18.0 // indirect
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||||
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
|
||||||
@@ -202,6 +238,7 @@ require (
|
|||||||
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
github.com/libp2p/go-libp2p-pubsub v0.11.0 // indirect
|
||||||
github.com/libp2p/go-msgio v0.3.0 // indirect
|
github.com/libp2p/go-msgio v0.3.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
|
github.com/mailru/easyjson v0.7.7 // indirect
|
||||||
github.com/manifoldco/promptui v0.9.0 // indirect
|
github.com/manifoldco/promptui v0.9.0 // indirect
|
||||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
@@ -221,20 +258,26 @@ require (
|
|||||||
github.com/mitchellh/go-ps v1.0.0 // indirect
|
github.com/mitchellh/go-ps v1.0.0 // indirect
|
||||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
github.com/mitchellh/reflectwalk v1.0.2 // indirect
|
||||||
github.com/moby/buildkit v0.17.2 // indirect
|
github.com/moby/buildkit v0.25.0 // indirect
|
||||||
github.com/moby/docker-image-spec v1.3.1 // indirect
|
github.com/moby/docker-image-spec v1.3.1 // indirect
|
||||||
|
github.com/moby/go-archive v0.1.0 // indirect
|
||||||
github.com/moby/locker v1.0.1 // indirect
|
github.com/moby/locker v1.0.1 // indirect
|
||||||
github.com/moby/patternmatcher v0.6.0 // 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/capability v0.4.0 // indirect
|
||||||
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
github.com/moby/sys/mountinfo v0.7.2 // indirect
|
||||||
github.com/moby/sys/sequential v0.6.0 // indirect
|
github.com/moby/sys/sequential v0.6.0 // indirect
|
||||||
github.com/moby/sys/signal v0.7.1 // indirect
|
github.com/moby/sys/signal v0.7.1 // indirect
|
||||||
|
github.com/moby/sys/symlink v0.3.0 // indirect
|
||||||
github.com/moby/sys/user v0.4.0 // indirect
|
github.com/moby/sys/user v0.4.0 // indirect
|
||||||
github.com/moby/sys/userns v0.1.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/morikuni/aec v1.0.0 // indirect
|
||||||
github.com/mr-tron/base58 v1.2.0 // indirect
|
github.com/mr-tron/base58 v1.2.0 // indirect
|
||||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||||
github.com/muesli/termenv v0.16.0 // indirect
|
|
||||||
github.com/multiformats/go-base32 v0.1.0 // indirect
|
github.com/multiformats/go-base32 v0.1.0 // indirect
|
||||||
github.com/multiformats/go-base36 v0.2.0 // indirect
|
github.com/multiformats/go-base36 v0.2.0 // indirect
|
||||||
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
github.com/multiformats/go-multiaddr v0.13.0 // indirect
|
||||||
@@ -245,6 +288,7 @@ require (
|
|||||||
github.com/multiformats/go-multistream v0.5.0 // indirect
|
github.com/multiformats/go-multistream v0.5.0 // indirect
|
||||||
github.com/multiformats/go-varint v0.0.7 // indirect
|
github.com/multiformats/go-varint v0.0.7 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.22.0 // indirect
|
||||||
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
github.com/opencontainers/runtime-spec v1.2.1 // indirect
|
||||||
@@ -269,13 +313,16 @@ require (
|
|||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/rs/xid v1.5.0 // indirect
|
github.com/rs/xid v1.5.0 // indirect
|
||||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 // indirect
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 // indirect
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 // indirect
|
github.com/secure-systems-lab/go-securesystemslib v0.6.0 // indirect
|
||||||
|
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b // indirect
|
||||||
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
github.com/shibumi/go-pathspec v1.3.0 // indirect
|
||||||
github.com/shopspring/decimal v1.4.0 // indirect
|
github.com/shopspring/decimal v1.4.0 // indirect
|
||||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
github.com/shurcooL/sanitized_anchor_name v1.0.0 // indirect
|
||||||
github.com/siderolabs/gen v0.4.8 // indirect
|
github.com/siderolabs/gen v0.4.8 // indirect
|
||||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 // indirect
|
||||||
github.com/slackhq/nebula v1.6.1 // indirect
|
github.com/slackhq/nebula v1.6.1 // indirect
|
||||||
github.com/smallstep/certificates v0.26.1 // indirect
|
github.com/smallstep/certificates v0.26.1 // indirect
|
||||||
github.com/smallstep/nosql v0.6.1 // indirect
|
github.com/smallstep/nosql v0.6.1 // indirect
|
||||||
@@ -284,37 +331,38 @@ require (
|
|||||||
github.com/smallstep/truststore v0.13.0 // indirect
|
github.com/smallstep/truststore v0.13.0 // indirect
|
||||||
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
github.com/spaolacci/murmur3 v1.1.0 // indirect
|
||||||
github.com/spf13/cast v1.7.0 // indirect
|
github.com/spf13/cast v1.7.0 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
github.com/stoewer/go-strcase v1.2.0 // indirect
|
github.com/stoewer/go-strcase v1.2.0 // indirect
|
||||||
github.com/stretchr/objx v0.5.2 // indirect
|
github.com/stretchr/objx v0.5.2 // indirect
|
||||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 // indirect
|
||||||
github.com/theupdateframework/notary v0.7.0 // indirect
|
github.com/theupdateframework/notary v0.7.0 // indirect
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 // indirect
|
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 // indirect
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c // indirect
|
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 // indirect
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 // indirect
|
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f // indirect
|
||||||
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 // indirect
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea // indirect
|
||||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab // indirect
|
||||||
github.com/urfave/cli v1.22.16 // indirect
|
github.com/urfave/cli v1.22.17 // indirect
|
||||||
github.com/vbatts/tar-split v0.11.6 // indirect
|
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||||
github.com/vishvananda/netns v0.0.5 // indirect
|
github.com/vishvananda/netns v0.0.5 // indirect
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
|
github.com/xhit/go-str2duration/v2 v2.1.0 // indirect
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 // indirect
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||||
|
github.com/zclconf/go-cty v1.17.0 // indirect
|
||||||
github.com/zeebo/blake3 v0.2.4 // indirect
|
github.com/zeebo/blake3 v0.2.4 // indirect
|
||||||
go.etcd.io/bbolt v1.4.0 // indirect
|
go.etcd.io/bbolt v1.4.3 // indirect
|
||||||
go.opencensus.io v0.24.0 // indirect
|
go.opencensus.io v0.24.0 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 // indirect
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 // indirect
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.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 v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||||
@@ -323,34 +371,48 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
|
||||||
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
go.opentelemetry.io/otel/log v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/metric v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0 // indirect
|
go.opentelemetry.io/otel/sdk v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 // indirect
|
go.opentelemetry.io/otel/trace v1.36.0 // indirect
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.step.sm/cli-utils v0.9.0 // indirect
|
go.step.sm/cli-utils v0.9.0 // indirect
|
||||||
go.step.sm/crypto v0.45.0 // indirect
|
go.step.sm/crypto v0.45.0 // indirect
|
||||||
go.step.sm/linkedca v0.20.1 // indirect
|
go.step.sm/linkedca v0.20.1 // indirect
|
||||||
go.uber.org/automaxprocs v1.5.3 // indirect
|
go.uber.org/automaxprocs v1.5.3 // indirect
|
||||||
go.uber.org/mock v0.5.0 // indirect
|
go.uber.org/mock v0.6.0 // indirect
|
||||||
go.uber.org/multierr v1.11.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/zap/exp v0.2.0 // indirect
|
go.uber.org/zap/exp v0.2.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 // indirect
|
||||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e // indirect
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||||
golang.org/x/mod v0.24.0 // indirect
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/time v0.8.0 // indirect
|
golang.org/x/text v0.28.0 // indirect
|
||||||
golang.org/x/tools v0.30.0 // indirect
|
golang.org/x/time v0.11.0 // indirect
|
||||||
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
|
||||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
gopkg.in/evanphx/json-patch.v4 v4.12.0 // indirect
|
||||||
|
gopkg.in/inf.v0 v0.9.1 // indirect
|
||||||
|
gopkg.in/ini.v1 v1.67.0 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 // indirect
|
||||||
howett.net/plist v1.0.0 // indirect
|
howett.net/plist v1.0.0 // indirect
|
||||||
|
k8s.io/api v0.32.3 // indirect
|
||||||
|
k8s.io/apimachinery v0.32.3 // indirect
|
||||||
|
k8s.io/client-go v0.32.3 // indirect
|
||||||
|
k8s.io/klog/v2 v2.130.1 // indirect
|
||||||
|
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f // indirect
|
||||||
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 // indirect
|
||||||
lukechampine.com/blake3 v1.3.0 // indirect
|
lukechampine.com/blake3 v1.3.0 // indirect
|
||||||
modernc.org/libc v1.61.13 // indirect
|
modernc.org/libc v1.61.13 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.8.2 // indirect
|
modernc.org/memory v1.8.2 // indirect
|
||||||
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 // indirect
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 // indirect
|
||||||
|
sigs.k8s.io/yaml v1.4.0 // indirect
|
||||||
|
tags.cncf.io/container-device-interface v1.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
|||||||
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4 h1:0GWE/FUsXhf6C+jAkWgYm7X9tK8cuEIfy19DBn6B6bY=
|
||||||
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
cloud.google.com/go/auth/oauth2adapt v0.2.4/go.mod h1:jC/jOpwFP6JBxhB3P5Rr0a9HLMC/Pe3eaL4NmdvqPtc=
|
||||||
cloud.google.com/go/compute v1.28.0 h1:OPtBxMcheSS+DWfci803qvPly3d4w7Eu5ztKBcFfzwk=
|
cloud.google.com/go/compute v1.19.3 h1:DcTwsFgGev/wV5+q8o2fzgcHOaac+DKGC91ZlvpsQds=
|
||||||
cloud.google.com/go/compute/metadata v0.6.0 h1:A6hENjEsCDtC1k8byVsgwvVcioamEHvZ4j01OwKxG9I=
|
cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU=
|
||||||
cloud.google.com/go/compute/metadata v0.6.0/go.mod h1:FjyFAW1MW0C203CEOMDTu3Dk1FlqW3Rga40jzHL4hfg=
|
cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo=
|
||||||
cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU=
|
cloud.google.com/go/iam v1.2.1 h1:QFct02HRb7H12J/3utj0qf5tobFh9V4vR6h9eX5EBRU=
|
||||||
cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g=
|
cloud.google.com/go/iam v1.2.1/go.mod h1:3VUIJDPpwT6p/amXRC5GY8fCCh70lxPygguVtI0Z4/g=
|
||||||
cloud.google.com/go/kms v1.19.0 h1:x0OVJDl6UH1BSX4THKlMfdcFWoE4ruh90ZHuilZekrU=
|
cloud.google.com/go/kms v1.19.0 h1:x0OVJDl6UH1BSX4THKlMfdcFWoE4ruh90ZHuilZekrU=
|
||||||
@@ -18,16 +18,18 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
|||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
|
||||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
|
||||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 h1:59MxjQVfjXsBpLy+dbd2/ELV5ofnUkUZBvWSC85sheA=
|
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
|
||||||
github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0/go.mod h1:OahwfttHWG6eJ0clwcfBAHoDI6X/LV/15hx/wlMZSrU=
|
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96 h1:cTp8I5+VIoKjsnZuH8vjyaysT/ses3EvZeaV/1UkF2M=
|
||||||
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
github.com/AndreasBriese/bbloom v0.0.0-20190825152654-46b345b51c96/go.mod h1:bOvUY6CB00SOBii9/FifXqc0awNKxLFCL/+pkDPuyl8=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||||
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/BurntSushi/toml v1.4.0 h1:kuoIxZQy2WRRk1pttg9asf+WVv6tWQuBNVmK8+nqPr0=
|
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
|
||||||
github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||||
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ=
|
||||||
|
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e h1:rd4bOvKmDIx0WeTv9Qz+hghsgyjikFiPrseXHlKepO0=
|
||||||
|
github.com/DefangLabs/secret-detector v0.0.0-20250403165618-22662109213e/go.mod h1:blbwPQh4DTlCZEfk1BLU4oMIhLda2U+A840Uag9DsZw=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||||
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
|
||||||
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI=
|
||||||
@@ -36,8 +38,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q
|
|||||||
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y=
|
||||||
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
github.com/Masterminds/semver/v3 v3.1.1/go.mod h1:VPu/7SZ7ePZ3QOrcuXROw5FAcLl4a0cBrbBpGY/8hQs=
|
||||||
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.2.0/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1 h1:RN9w6+7QoMeJVGyfmbcgs28Br8cvmnucEXnY0rYXWg0=
|
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
|
||||||
github.com/Masterminds/semver/v3 v3.2.1/go.mod h1:qvl/7zhW3nngYb5+80sSMF+FG2BjYrf8m9wsX0PNOMQ=
|
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
|
||||||
github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
|
github.com/Masterminds/sprig/v3 v3.2.1/go.mod h1:UoaO7Yp8KlPnJIYWTFkMaqPUYKTfGFPhxNuwnnxkKlk=
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
github.com/Masterminds/sprig/v3 v3.2.3 h1:eL2fZNezLomi0uOLqjQoN6BfsDD+fyLtgbJMAj9n6YA=
|
||||||
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
github.com/Masterminds/sprig/v3 v3.2.3/go.mod h1:rXcFaZ2zZbLRJv/xSysmlgIM1u11eBaRMhvYXJNkGuM=
|
||||||
@@ -48,12 +50,15 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||||
github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA=
|
github.com/Microsoft/hcsshim v0.13.0 h1:/BcXOiS6Qi7N9XqUcv27vkIuVOkBEcWstd2pMlWSeaA=
|
||||||
github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok=
|
github.com/Microsoft/hcsshim v0.13.0/go.mod h1:9KWJ/8DgU+QzYGupX4tzMhRQE8h6w90lH6HAaclpEok=
|
||||||
|
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
||||||
|
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
|
||||||
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
|
github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8=
|
||||||
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
|
github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q=
|
||||||
|
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d h1:hi6J4K6DKrR4/ljxn6SF6nURyu785wKMuQcjt7H3VCQ=
|
||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
github.com/Shopify/logrus-bugsnag v0.0.0-20170309145241-6dbc35f2c30d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d h1:UrqY+r/OJnIp5u0s1SbQ8dVfLCZJsnvazdBP5hS4iRs=
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
|
||||||
github.com/Shopify/logrus-bugsnag v0.0.0-20171204204709-577dee27f20d/go.mod h1:HI8ITrYtUY+O+ZhtlqUnD8+KwNPOyugEhfP9fdUIaEQ=
|
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
github.com/alecthomas/chroma/v2 v2.20.0 h1:sfIHpxPyR07/Oylvmcai3X/exDlE8+FA820NTz+9sGw=
|
||||||
@@ -68,6 +73,8 @@ github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092 h1:aM1
|
|||||||
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
|
github.com/anchore/go-struct-converter v0.0.0-20221118182256-c68fdcfa2092/go.mod h1:rYqSE9HbjzpHTI74vwPvae4ZVYZd1lue2ta6xHPdblA=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
|
||||||
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
|
||||||
|
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
|
||||||
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs=
|
||||||
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
|
||||||
@@ -78,38 +85,40 @@ github.com/armon/go-metrics v0.4.1/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+
|
|||||||
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
|
||||||
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI=
|
||||||
github.com/armon/go-radix v1.0.0/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/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
||||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||||
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
|
||||||
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.26.1 h1:5554eUqIYVWpU0YmeeYZ0wU64H2VLBs8TlhRB2L+EkA=
|
github.com/aws/aws-sdk-go-v2 v1.30.3 h1:jUeBtG0Ih+ZIFH0F4UkmL9w3cSpaMv9tYYDbzILP8dY=
|
||||||
github.com/aws/aws-sdk-go-v2 v1.26.1/go.mod h1:ffIFB97e2yNsv4aTSGkqtHnppsIJzw7G7BReUZ3jCXM=
|
github.com/aws/aws-sdk-go-v2 v1.30.3/go.mod h1:nIQjQVp5sfpQcTc9mPSr1B0PaWK5ByX9MOoDadSN4lc=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.13 h1:WbKW8hOzrWoOA/+35S5okqO/2Ap8hkkFUzoW8Hzq24A=
|
github.com/aws/aws-sdk-go-v2/config v1.27.27 h1:HdqgGt1OAP0HkEDDShEl0oSYa9ZZBSOmKpdpsDMdO90=
|
||||||
github.com/aws/aws-sdk-go-v2/config v1.27.13/go.mod h1:XLiyiTMnguytjRER7u5RIkhIqS8Nyz41SwAWb4xEjxs=
|
github.com/aws/aws-sdk-go-v2/config v1.27.27/go.mod h1:MVYamCg76dFNINkZFu4n4RjDixhVr51HLj4ErWzrVwg=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.13 h1:XDCJDzk/u5cN7Aple7D/MiAhx1Rjo/0nueJ0La8mRuE=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.27 h1:2raNba6gr2IfA0eqqiP2XiQ0UVOpGPgDSi0I9iAP+UI=
|
||||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.13/go.mod h1:FMNcjQrmuBYvOTZDtOLCIu0esmxjF7RuA/89iSXWzQI=
|
github.com/aws/aws-sdk-go-v2/credentials v1.17.27/go.mod h1:gniiwbGahQByxan6YjQUMcW4Aov6bLC3m+evgcoN4r4=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1 h1:FVJ0r5XTHSmIHJV6KuDmdYhEpvlHpiSd38RQWhut5J4=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11 h1:KreluoV8FZDEtI6Co2xuNk/UqI9iwMrOx/87PBNIKqw=
|
||||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.1/go.mod h1:zusuAeqezXzAB24LGuzuekqMAEgWkVYukBec3kr3jUg=
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.11/go.mod h1:SeSUYBLsMYFoRvHE0Tjvn7kbxaUhl75CJi1sbfhMxkU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5 h1:aw39xVGeRWlWx9EzGVnhOR4yOjQDHPQ6o6NmBlscyQg=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15 h1:SoNJ4RlFEQEbtDcCEt+QG56MY4fm4W8rYirAmq+/DdU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.5/go.mod h1:FSaRudD0dXiMPK2UjknVwwTYyZMRsHv3TtkabsZih5I=
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.3.15/go.mod h1:U9ke74k1n2bf+RIgoX1SXFed1HLs51OgUSs+Ph0KJP8=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5 h1:PG1F3OD1szkuQPzDw3CIQsRIrtTlUC3lP84taWzHlq0=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15 h1:C6WHdGnTDIYETAm5iErQUiVNsclNx9qbJVPIt03B6bI=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.5/go.mod h1:jU1li6RFryMz+so64PpKtudI+QzbKoIEivqdf6LNpOc=
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.6.15/go.mod h1:ZQLZqhcu+JhSrA9/NXRm8SkDvsycE+JkV3WGY41e+IM=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0 h1:hT8rVHwugYE2lEfdFE0QWVo81lF7jMrYJVDWI+f+VxU=
|
||||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.0/go.mod h1:8tu/lYfQfFe6IGnaOdrpVgEL2IrrDOf6/m9RQum4NkY=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2 h1:Ji0DY1xUsUr3I8cHps0G+XM3WWU16lP6yG8qu1GAZAs=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3 h1:dT3MqvGhSoaIhRseqw2I0yH81l7wiR2vjs57O51EAm8=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.2/go.mod h1:5CsjAbs3NlGQyZNFACh+zztPDI7fU6eW9QsxjfnuBKg=
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.11.3/go.mod h1:GlAeCkHwugxdHaueRr4nhPuY+WW+gR8UjlcqzPr1SPI=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7 h1:ogRAwT1/gxJBcSWDMZlgyFUM962F51A5CRhDLbxLdmo=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17 h1:HGErhhrxZlQ044RiM+WdoZxp0p+EGM62y3L6pwA4olE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.7/go.mod h1:YCsIZhXfRPLFFCl5xxY+1T9RKzOKjCut+28JSX2DnAk=
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.11.17/go.mod h1:RkZEx4l0EHYDJpWppMJ3nD9wZJAa8/0lq9aVC+r2UII=
|
||||||
github.com/aws/aws-sdk-go-v2/service/kms v1.31.1 h1:5wtyAwuUiJiM3DHYeGZmP5iMonM7DFBWAEaaVPHYZA0=
|
github.com/aws/aws-sdk-go-v2/service/kms v1.31.1 h1:5wtyAwuUiJiM3DHYeGZmP5iMonM7DFBWAEaaVPHYZA0=
|
||||||
github.com/aws/aws-sdk-go-v2/service/kms v1.31.1/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ=
|
github.com/aws/aws-sdk-go-v2/service/kms v1.31.1/go.mod h1:2snWQJQUKsbN66vAawJuOGX7dr37pfOq9hb0tZDGIqQ=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.6 h1:o5cTaeunSpfXiLTIBx5xo2enQmiChtu1IBbzXnfU9Hs=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4 h1:BXx0ZIxvrJdSgSvKTZ+yRBeSqqgPM89VPlulEcl37tM=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sso v1.20.6/go.mod h1:qGzynb/msuZIE8I75DVRCUXw3o3ZyBmUvMwQ2t/BrGM=
|
github.com/aws/aws-sdk-go-v2/service/sso v1.22.4/go.mod h1:ooyCOXjvJEsUw7x+ZDHeISPMhtwI3ZCB7ggFMcFfWLU=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.0 h1:Qe0r0lVURDDeBQJ4yP+BOrJkvkiCo/3FH/t+wY11dmw=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4 h1:yiwVzJW2ZxZTurVbYWA7QOrAaCYQR72t0wrSBfoesUE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.24.0/go.mod h1:mUYPBhaF2lGiukDEjJX2BLRRKTmoUSitGDUgM4tRxak=
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.26.4/go.mod h1:0oxfLkpz3rQ/CHlx5hB7H69YUpFiI1tql6Q6Ne+1bCw=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.7 h1:et3Ta53gotFR4ERLXXHIHl/Uuk1qYpP5uU7cvNql8ns=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3 h1:ZsDKRLXGWHk8WdtyYMoGNO7bTudrvuKpDKgMVRlepGE=
|
||||||
github.com/aws/aws-sdk-go-v2/service/sts v1.28.7/go.mod h1:FZf1/nKNEkHdGGJP/cI2MoIMquumuRK6ol3QQJNDxmw=
|
github.com/aws/aws-sdk-go-v2/service/sts v1.30.3/go.mod h1:zwySh8fpFyXp9yOr/KVzxOl8SRqgf/IDw5aUt9UKFcQ=
|
||||||
github.com/aws/smithy-go v1.20.2 h1:tbp628ireGtzcHDDmLT/6ADHidqnwgF57XOXZe6tp4Q=
|
github.com/aws/smithy-go v1.20.3 h1:ryHwveWzPV5BIof6fyDvor6V3iUL7nTfiTKXHiW05nE=
|
||||||
github.com/aws/smithy-go v1.20.2/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
github.com/aws/smithy-go v1.20.3/go.mod h1:krry+ya/rV9RDcV/Q16kpu6ypI4K2czasz0NC3qS14E=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||||
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
|
||||||
@@ -191,28 +200,25 @@ github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38
|
|||||||
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
|
||||||
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
|
||||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ=
|
||||||
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
|
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
|
||||||
github.com/cloudflare/cfssl v1.6.4 h1:NMOvfrEjFfC63K3SGXgAnFdsgkmiq4kATme5BfcqrO8=
|
|
||||||
github.com/cloudflare/cfssl v1.6.4/go.mod h1:8b3CQMxfWPAeom3zBnGJ6sd+G1NkL5TXqmDXacb+1J0=
|
|
||||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I=
|
||||||
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ=
|
||||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE=
|
||||||
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4=
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5 h1:p4ih4Jb6VgGPLPxh3fSFVKAjFHtZd+7HVLCSFzcFx9Y=
|
github.com/compose-spec/compose-go/v2 v2.9.0 h1:UHSv/QHlo6QJtrT4igF1rdORgIUhDo1gWuyJUoiNNIM=
|
||||||
github.com/compose-spec/compose-go/v2 v2.4.5/go.mod h1:lFN0DrMxIncJGYAXTfWuajfwj5haBJqrBkarHcnjJKc=
|
github.com/compose-spec/compose-go/v2 v2.9.0/go.mod h1:Oky9AZGTRB4E+0VbTPZTUu4Kp+oEMMuwZXZtPPVT1iE=
|
||||||
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
|
github.com/containerd/cgroups v1.1.0 h1:v8rEWFl6EoqHB+swVNjVoCJE8o3jX7e8nqBGPLaDFBM=
|
||||||
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
|
github.com/containerd/cgroups v1.1.0/go.mod h1:6ppBcbh/NOOUU+dMKrykgaBnK9lCIBxHqJDGwsa1mIw=
|
||||||
github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
|
github.com/containerd/cgroups/v3 v3.0.5 h1:44na7Ud+VwyE7LIoJ8JTNQOa549a8543BmzaJHo6Bzo=
|
||||||
github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
|
github.com/containerd/cgroups/v3 v3.0.5/go.mod h1:SA5DLYnXO8pTGYiAHXz94qvLQTKfVM5GEVisn4jpins=
|
||||||
github.com/containerd/console v1.0.4 h1:F2g4+oChYvBTsASRTz8NP6iIAi97J3TtSAsLbIFn4ro=
|
github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc=
|
||||||
github.com/containerd/console v1.0.4/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
|
||||||
github.com/containerd/containerd v1.7.24 h1:zxszGrGjrra1yYJW/6rhm9cJ1ZQ8rkKBR48brqsa7nA=
|
|
||||||
github.com/containerd/containerd v1.7.24/go.mod h1:7QUzfURqZWCZV7RLNEn1XjUCQLEf0bkaK4GjUaZehxw=
|
|
||||||
github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0=
|
github.com/containerd/containerd/api v1.9.0 h1:HZ/licowTRazus+wt9fM6r/9BQO7S0vD5lMcWspGIg0=
|
||||||
github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI=
|
github.com/containerd/containerd/api v1.9.0/go.mod h1:GhghKFmTR3hNtyznBoQ0EMWr9ju5AqHjcZPsSpTKutI=
|
||||||
github.com/containerd/containerd/v2 v2.1.1 h1:znnkm7Ajz8lg8BcIPMhc/9yjBRN3B+OkNKqKisKfwwM=
|
github.com/containerd/containerd/v2 v2.1.4 h1:/hXWjiSFd6ftrBOBGfAZ6T30LJcx1dBjdKEeI8xucKQ=
|
||||||
github.com/containerd/containerd/v2 v2.1.1/go.mod h1:zIfkQj4RIodclYQkX7GSSswSwgP8d/XxDOtOAoSDIGU=
|
github.com/containerd/containerd/v2 v2.1.4/go.mod h1:8C5QV9djwsYDNhxfTCFjWtTBZrqjditQ4/ghHSYjnHM=
|
||||||
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
|
||||||
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
github.com/containerd/continuity v0.4.5/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
|
||||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||||
@@ -223,8 +229,8 @@ github.com/containerd/fifo v1.1.0 h1:4I2mbh5stb1u6ycIABlBw9zgtlK8viPI9QkQNRQEEmY
|
|||||||
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
|
github.com/containerd/fifo v1.1.0/go.mod h1:bmC4NWMbXlt2EZ0Hc7Fx7QzTFxgPID13eH0Qu+MAb2o=
|
||||||
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
|
||||||
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
|
||||||
github.com/containerd/nydus-snapshotter v0.14.0 h1:6/eAi6d7MjaeLLuMO8Udfe5GVsDudmrDNO4SGETMBco=
|
github.com/containerd/nydus-snapshotter v0.15.2 h1:qsHI4M+Wwrf6Jr4eBqhNx8qh+YU0dSiJ+WPmcLFWNcg=
|
||||||
github.com/containerd/nydus-snapshotter v0.14.0/go.mod h1:TT4jv2SnIDxEBu4H2YOvWQHPOap031ydTaHTuvc5VQk=
|
github.com/containerd/nydus-snapshotter v0.15.2/go.mod h1:FfwH2KBkNYoisK/e+KsmNr7xTU53DmnavQHMFOcXwfM=
|
||||||
github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
|
github.com/containerd/platforms v1.0.0-rc.1 h1:83KIq4yy1erSRgOVHNk1HYdPvzdJ5CnsWaRoJX4C41E=
|
||||||
github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
|
github.com/containerd/platforms v1.0.0-rc.1/go.mod h1:J71L7B+aiM5SdIEqmd9wp6THLVRzJGXfNuWCZCllLA4=
|
||||||
github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y=
|
github.com/containerd/plugin v1.0.0 h1:c8Kf1TNl6+e2TtMHZt+39yAPDbouRH9WAToRjex483Y=
|
||||||
@@ -246,18 +252,20 @@ github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8
|
|||||||
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
github.com/cpuguy83/go-md2man v1.0.10/go.mod h1:SmD6nW6nTyfqj6ABTjUi3V3JVMnlJmwcJI5acqYI6dE=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
|
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||||
github.com/creack/pty v1.1.21 h1:1/QdRyBaHHJP61QkWMXlOIBfsgdDeeKfK8SYVUWJKf0=
|
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||||
github.com/creack/pty v1.1.21/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0=
|
github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0=
|
||||||
github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis=
|
github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||||
|
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c h1:pFUpOrbxDR6AkioZ1ySsx5yxlDQZ8stG2b88gTPxgJU=
|
||||||
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c/go.mod h1:6UhI8N9EjYm1c2odKpFpAYeR8dsBeM7PtzQhRgxRr9U=
|
||||||
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ=
|
||||||
@@ -288,24 +296,26 @@ 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/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/docker/buildx v0.18.0 h1:rSauXHeJt90NvtXrLK5J992Eb0UPJZs2vV3u1zTf1nE=
|
github.com/docker/buildx v0.29.1 h1:58hxM5Z4mnNje3G5NKfULT9xCr8ooM8XFtlfUK9bKaA=
|
||||||
github.com/docker/buildx v0.18.0/go.mod h1:JGNSshOhHs5FhG3u51jXUf4lLOeD2QBIlJ2vaRB67p4=
|
github.com/docker/buildx v0.29.1/go.mod h1:J4EFv6oxlPiV1MjO0VyJx2u5tLM7ImDEl9zyB8d4wPI=
|
||||||
github.com/docker/cli v27.5.0+incompatible h1:aMphQkcGtpHixwwhAXJT1rrK/detk2JIvDaFkLctbGM=
|
github.com/docker/cli v28.5.0+incompatible h1:crVqLrtKsrhC9c00ythRx435H8LiQnUKRtJLRR+Auxk=
|
||||||
github.com/docker/cli v27.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
github.com/docker/cli v28.5.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||||
github.com/docker/compose/v2 v2.31.0 h1:8Sm0c4MjIhksguxIA5koYMXoTJDAp/CaZ1cdZrMvMdw=
|
github.com/docker/cli-docs-tool v0.10.0 h1:bOD6mKynPQgojQi3s2jgcUWGp/Ebqy1SeCr9VfKQLLU=
|
||||||
github.com/docker/compose/v2 v2.31.0/go.mod h1:oQq3UDEdsnB3AUO72AxaoeLbkCgmUu1+8tLzvmphmXA=
|
github.com/docker/cli-docs-tool v0.10.0/go.mod h1:5EM5zPnT2E7yCLERZmrDA234Vwn09fzRHP4aX1qwp1U=
|
||||||
|
github.com/docker/compose/v2 v2.40.0 h1:y6wuWuxvoIHgDdo9J12OGnXAQmLviRInwFKSEqKW5Cs=
|
||||||
|
github.com/docker/compose/v2 v2.40.0/go.mod h1:AZk82SDjtT2YI+7fXLjm8U0jxZU3Xrr0XOfL7OIsP4M=
|
||||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||||
github.com/docker/docker v27.4.0-rc.2+incompatible h1:9OJjVGtelk/zGC3TyKweJ29b9Axzh0s/0vtU4mneumE=
|
github.com/docker/docker v28.5.0+incompatible h1:ZdSQoRUE9XxhFI/B8YLvhnEFMmYN9Pp8Egd2qcaFk1E=
|
||||||
github.com/docker/docker v27.4.0-rc.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
github.com/docker/docker v28.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
|
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
|
||||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
|
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
|
||||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
|
||||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
|
||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8=
|
||||||
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA=
|
||||||
github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
|
github.com/docker/go-metrics v0.0.0-20180209012529-399ea8c73916/go.mod h1:/u0gXw0Gay3ceNrsHubL3BtdOL2fHf93USgMTe0W5dI=
|
||||||
@@ -319,8 +329,12 @@ github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25Kn
|
|||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
|
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
|
||||||
|
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
|
||||||
|
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
|
||||||
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
github.com/elastic/gosigar v0.14.3 h1:xwkKwPia+hSfg9GqrCUKYdId102m9qTJIIr7egmK/uo=
|
||||||
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
github.com/elastic/gosigar v0.14.3/go.mod h1:iXRIGg2tLnu7LBdpqzyQfGDEidKCfWcCMS0WKyPWoMs=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g=
|
||||||
|
github.com/emicklei/go-restful/v3 v3.11.0/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.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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
@@ -340,11 +354,15 @@ github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJn
|
|||||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsevents v0.2.0 h1:BRlvlqjvNTfogHfeBOFvSC9N0Ddy+wzQCQukyoD7o/c=
|
||||||
|
github.com/fsnotify/fsevents v0.2.0/go.mod h1:B3eEk39i4hz8y1zaWS/wPrAP4O6wkIl7HQwKBr1qH/w=
|
||||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
|
github.com/fvbommel/sortorder v1.1.0 h1:fUmoe+HLsBTctBDoaBwpQo5N+nrCp8g/BjKb/6ZQmYw=
|
||||||
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
github.com/fvbommel/sortorder v1.1.0/go.mod h1:uk88iVf1ovNn1iLfgUVU2F9o5eO30ui720w+kxuqRs0=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||||
|
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k=
|
||||||
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||||
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
github.com/go-kit/kit v0.4.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
|
||||||
@@ -362,10 +380,18 @@ github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KE
|
|||||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
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-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||||
|
github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE=
|
||||||
|
github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k=
|
||||||
|
github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14=
|
||||||
|
github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE=
|
||||||
|
github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ=
|
||||||
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
github.com/go-sql-driver/mysql v1.3.0/go.mod h1:zAC/RDZ24gD3HViQzih4MyKcchzm+sOG5ZlKdlhCg5w=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
||||||
@@ -373,8 +399,8 @@ github.com/go-stack/stack v1.6.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
|
|||||||
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
|
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 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
|
||||||
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0 h1:dhn8MZ1gZ0mzeodTG3jt5Vj/o87xZKuNAprG2mQfMfc=
|
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||||
github.com/go-viper/mapstructure/v2 v2.0.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0=
|
||||||
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
github.com/goccy/go-yaml v1.17.1 h1:LI34wktB2xEE3ONG/2Ar54+/HJVBriAGJ55PHls4YuY=
|
||||||
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.17.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
@@ -388,10 +414,12 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
|
|||||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
|
||||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
github.com/golang/glog v1.2.4 h1:CNNw5U8lSiiBk7druxtSHHTsRWcxKoac6kZKm2peBBc=
|
github.com/golang/glog v1.2.5 h1:DrW6hGnjIhtvhOIiAKT6Psh/Kd/ldepEa81DKeiRJ5I=
|
||||||
github.com/golang/glog v1.2.4/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w=
|
||||||
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
@@ -424,6 +452,8 @@ github.com/google/certificate-transparency-go v1.1.8-0.20240110162603-74a5dd3317
|
|||||||
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
github.com/google/flatbuffers v1.12.1/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
github.com/google/flatbuffers v24.3.25+incompatible h1:CX395cjN9Kke9mmalRoL3d81AtFUxJM+yDthflgJGkI=
|
||||||
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
github.com/google/flatbuffers v24.3.25+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
||||||
|
github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I=
|
||||||
|
github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U=
|
||||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
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.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
@@ -444,10 +474,12 @@ github.com/google/go-tpm-tools v0.4.4/go.mod h1:T8jXkp2s+eltnCDIsXR84/MTcVU9Ja7b
|
|||||||
github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus=
|
github.com/google/go-tspi v0.3.0 h1:ADtq8RKfP+jrTyIWIZDIYcKOMecRqNJFOew2IT0Inus=
|
||||||
github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
|
github.com/google/go-tspi v0.3.0/go.mod h1:xfMGI3G0PhxCdNVcYr1C4C+EizojDg/TXuX5by8CiHI=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0=
|
||||||
|
github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
|
||||||
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
@@ -479,9 +511,13 @@ github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
|
|||||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||||
|
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||||
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
github.com/hashicorp/go-immutable-radix v1.3.1 h1:DKHmCUm2hRBK510BaiZlwvpD40f8bJFeZnpfm2KLowc=
|
||||||
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
github.com/hashicorp/go-immutable-radix v1.3.1/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.1.0 h1:CUW5RYIcysz+D3B+l1mDeXrQ7fUvGGCwJfdASSzbrfo=
|
||||||
|
github.com/hashicorp/go-immutable-radix/v2 v2.1.0/go.mod h1:hgdqLXA4f6NIjRVisM1TJ9aOJVNRqKZj+xDGF6m7PBw=
|
||||||
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
github.com/hashicorp/go-msgpack v0.5.3/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||||
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
|
github.com/hashicorp/go-msgpack v0.5.5 h1:i9R9JSrqIz0QVLz3sz+i3YJdT7TTSLcfLLzJi9aZTuI=
|
||||||
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
github.com/hashicorp/go-msgpack v0.5.5/go.mod h1:ahLV/dePpqEmjfWmKiqvPkv/twdG7iPBM1vqhUKIvfM=
|
||||||
@@ -498,8 +534,9 @@ github.com/hashicorp/go-sockaddr v1.0.6/go.mod h1:uoUUmtwU7n9Dv3O4SNLeFvg0SxQ3ly
|
|||||||
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
github.com/hashicorp/go-syslog v1.0.0 h1:KaodqZuhUoZereWVIYmpUgZysurB1kBLX2j0MwMrUAE=
|
||||||
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
|
||||||
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.1 h1:fv1ep09latC32wFoVwnqcnKJGnMSdBanPczbHAYm1BE=
|
|
||||||
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY=
|
||||||
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||||
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
|
||||||
@@ -523,6 +560,8 @@ github.com/hashicorp/serf v0.10.1 h1:Z1H2J60yRKvfDYAOZLd2MU0ND4AH/WDz7xYHDWQsIPY
|
|||||||
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
github.com/hashicorp/serf v0.10.1/go.mod h1:yL2t6BqATOLGc5HF7qbFkTfXoPIY0WZdWHfEvMqbG+4=
|
||||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec h1:qv2VnGeEQHchGaZ/u7lxST/RaJw+cv273q79D81Xbog=
|
||||||
|
github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4DvxnHolD5P8pOtXigYlRuPLGl6moFx3ulM68=
|
||||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.3.1/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
github.com/huandu/xstrings v1.3.2/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE=
|
||||||
@@ -534,11 +573,13 @@ github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFck
|
|||||||
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
github.com/imdario/mergo v0.3.11/go.mod h1:jmQim1M+e3UYxmgPu/WyfjB3N3VflVyUjjjwH0dnCYA=
|
||||||
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4=
|
||||||
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY=
|
||||||
github.com/in-toto/in-toto-golang v0.5.0 h1:hb8bgwr0M2hGdDsLjkJ3ZqJ8JFLL/tgYdAxF/XEFBbY=
|
github.com/in-toto/in-toto-golang v0.9.0 h1:tHny7ac4KgtsfrG6ybU8gVOZux2H8jN05AXJ9EBM1XU=
|
||||||
github.com/in-toto/in-toto-golang v0.5.0/go.mod h1:/Rq0IZHLV7Ku5gielPT4wPHJfH1GdHMCq8+WPxw8/BE=
|
github.com/in-toto/in-toto-golang v0.9.0/go.mod h1:xsBVrVsHNsB61++S6Dy2vWosKhuA3lUTQd+eF9HdeMo=
|
||||||
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf h1:FtEj8sfIcaaBfAKrE1Cwb61YDtYq9JxChK1c7AKce7s=
|
||||||
|
github.com/inhies/go-bytesize v0.0.0-20220417184213-4913239db9cf/go.mod h1:yrqSXGoD/4EKfF26AOGzscPOgTTJcyAwM2rpixWT+t4=
|
||||||
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs=
|
||||||
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0=
|
||||||
github.com/ipfs/boxo v0.21.0 h1:XpGXb+TQQ0IUdYaeAxGzWjSs6ow/Lce148A/2IbRDVE=
|
github.com/ipfs/boxo v0.21.0 h1:XpGXb+TQQ0IUdYaeAxGzWjSs6ow/Lce148A/2IbRDVE=
|
||||||
@@ -643,15 +684,23 @@ github.com/jinzhu/inflection v0.0.0-20170102125226-1c35d901db3d/go.mod h1:h+uFLl
|
|||||||
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
|
||||||
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I=
|
||||||
|
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||||
|
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/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
|
||||||
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
|
||||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||||
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
||||||
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
|
github.com/json-iterator/go v1.1.9/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/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo=
|
||||||
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
|
||||||
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
|
github.com/juju/loggo v0.0.0-20190526231331-6e530bcce5d8/go.mod h1:vgyd7OREkbtVEN/8IXZe5Ooef3LQePvuBm9UWj6ZL8U=
|
||||||
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
|
||||||
|
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51/go.mod h1:CzGEWj7cYgsdH8dAjBGEr58BoE7ScuLd+fwFZ44+/x8=
|
||||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
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/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||||
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
github.com/klauspost/compress v1.12.3/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg=
|
||||||
@@ -666,6 +715,7 @@ github.com/koron/go-ssdp v0.0.4/go.mod h1:oDXq+E5IL5q0U8uSBcoAXzTzInwy5lEgC91HoK
|
|||||||
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
|
||||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
|
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
@@ -717,14 +767,18 @@ github.com/lmittmann/tint v1.0.5/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ
|
|||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
github.com/magiconair/properties v1.5.3/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||||
github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY=
|
|
||||||
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ=
|
||||||
|
github.com/magiconair/properties v1.8.9 h1:nWcCbLq1N2v/cpNsy5WvQ37Fb+YElfq20WJ/a8RkpQM=
|
||||||
|
github.com/magiconair/properties v1.8.9/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
||||||
|
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
|
||||||
|
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
|
||||||
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
|
||||||
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg=
|
||||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
|
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd h1:br0buuQ854V8u83wA0rVZ8ttrq5CpaPZdvrK0LP2lOk=
|
||||||
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
|
github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd/go.mod h1:QuCEs1Nt24+FYQEqAAncTDPJIuGs+LxK1MCiFL25pMU=
|
||||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
|
||||||
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
|
||||||
|
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||||
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
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=
|
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
|
||||||
@@ -755,6 +809,7 @@ github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/
|
|||||||
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw=
|
||||||
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos=
|
||||||
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ=
|
||||||
|
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||||
github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw=
|
github.com/mholt/acmez/v2 v2.0.3 h1:CgDBlEwg3QBp6s45tPQmFIBrkRIkBT4rW4orMM6p4sw=
|
||||||
@@ -796,30 +851,43 @@ github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR
|
|||||||
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||||
github.com/moby/buildkit v0.17.2 h1:/jgk/MuXbA7jeXMkknOpHYB+Ct4aNvQHkBB7SxD3D4U=
|
github.com/moby/buildkit v0.25.0 h1:cRgh74ymzyHxS5a/lsYT4OCyVU8iC3UgkwasIEUi0og=
|
||||||
github.com/moby/buildkit v0.17.2/go.mod h1:vr5vltV8wt4F2jThbNOChfbAklJ0DOW11w36v210hOg=
|
github.com/moby/buildkit v0.25.0/go.mod h1:phM8sdqnvgK2y1dPDnbwI6veUCXHOZ6KFSl6E164tkc=
|
||||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
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/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/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
|
github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg=
|
||||||
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
|
github.com/moby/locker v1.0.1/go.mod h1:S7SDdo5zpBK84bzzVlKr2V0hz+7x9hWbYC/kq7oQppc=
|
||||||
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
|
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/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/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk=
|
||||||
|
github.com/moby/sys/capability v0.4.0/go.mod h1:4g9IK291rVkms3LKCDOoYlnV8xKwoDTpIrNEE35Wq0I=
|
||||||
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg=
|
||||||
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4=
|
||||||
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
|
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/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 h1:PrQxdvxcGijdo6UXXo/lU/TvHUWyPhj7UOpSo8tuvk0=
|
||||||
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
|
github.com/moby/sys/signal v0.7.1/go.mod h1:Se1VGehYokAkrSQwL4tDzHvETwUZlnY7S5XtQ50mQp8=
|
||||||
|
github.com/moby/sys/symlink v0.3.0 h1:GZX89mEZ9u53f97npBy4Rc3vJKj7JBDj/PN2I22GrNU=
|
||||||
|
github.com/moby/sys/symlink v0.3.0/go.mod h1:3eNdhduHmYPcgsJtZXW1W4XUJdZGBIkttZ8xKqPUJq0=
|
||||||
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
|
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/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 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
|
||||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
|
||||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
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-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/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 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.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 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||||
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
github.com/mr-tron/base58 v1.1.2/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc=
|
||||||
@@ -856,6 +924,8 @@ github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOEL
|
|||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
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/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-20161129095857-cc309e4a2223/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/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||||
@@ -865,8 +935,8 @@ github.com/onsi/ginkgo/v2 v2.22.0 h1:Yed107/8DjTr0lKCNt7Dn8yQ6ybuDRQoMGrNFKzMfHg
|
|||||||
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
github.com/onsi/ginkgo/v2 v2.22.0/go.mod h1:7Du3c42kxCUegi0IImZ1wUQzMBVecgIHjR1C+NkhLQo=
|
||||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
github.com/onsi/gomega v1.9.0/go.mod h1:Ho0h+IUsWyvy1OpqCwxlQ/21gkhVunqlU8fDGcoTdcA=
|
||||||
github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8=
|
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
|
||||||
github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc=
|
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
|
||||||
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
github.com/opencontainers/go-digest v0.0.0-20170106003457-a6d0ee40d420/go.mod h1:cMLVZDEM3+U2I4VmLI6N8jQYUd2OVphdqWwCJHrFt2s=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
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/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
@@ -888,6 +958,7 @@ github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58/go.mod h1:DXv8WO4yhM
|
|||||||
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic=
|
||||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
|
||||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
|
||||||
|
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
|
||||||
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
|
github.com/peterbourgon/diskv/v3 v3.0.1 h1:x06SQA46+PKIUftmEujdwSEpIx8kR+M9eLYsUxeYveU=
|
||||||
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
github.com/peterbourgon/diskv/v3 v3.0.1/go.mod h1:kJ5Ny7vLdARGU3WUuy6uzO6T0nb/2gWcT1JiBvRmb5o=
|
||||||
github.com/pion/datachannel v1.5.6 h1:1IxKJntfSlYkpUj8LlYRSWpYiTTC02nUrOE8T3DqGeg=
|
github.com/pion/datachannel v1.5.6 h1:1IxKJntfSlYkpUj8LlYRSWpYiTTC02nUrOE8T3DqGeg=
|
||||||
@@ -1002,13 +1073,17 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
|
|||||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1 h1:PKK9DyHxif4LZo+uQSgXNqs0jj5+xZwwfKHgph2lxBw=
|
||||||
|
github.com/santhosh-tekuri/jsonschema/v6 v6.0.1/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU=
|
||||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
|
||||||
github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E=
|
github.com/schollz/jsonstore v1.1.0 h1:WZBDjgezFS34CHI+myb4s8GGpir3UMpy7vWoCeO0n6E=
|
||||||
github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg=
|
github.com/schollz/jsonstore v1.1.0/go.mod h1:15c6+9guw8vDRyozGjN3FoILt0wpruJk9Pi66vjaZfg=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I=
|
||||||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0 h1:b23VGrQhTA8cN2CbBw7/FulN9fTtqYUdS5+Oxzt+DUE=
|
github.com/secure-systems-lab/go-securesystemslib v0.6.0 h1:T65atpAVCJQK14UA57LMdZGpHi4QYSH/9FZyNGqMYIA=
|
||||||
github.com/secure-systems-lab/go-securesystemslib v0.4.0/go.mod h1:FGBZgq2tXWICsxWQW1msNf49F0Pf2Op5Htayx335Qbs=
|
github.com/secure-systems-lab/go-securesystemslib v0.6.0/go.mod h1:8Mtpo9JKks/qhPG4HGZ2LGMvrPbzuxwfz/f/zLfEWkk=
|
||||||
|
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b h1:h+3JX2VoWTFuyQEo87pStk/a99dzIO1mM9KxIyLPGTU=
|
||||||
|
github.com/serialx/hashring v0.0.0-20200727003509-22c0c7ab6b1b/go.mod h1:/yeG0My1xr/u+HZrFQ1tOQQQQrOawfyMUH13ai5brBc=
|
||||||
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI=
|
||||||
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
|
github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE=
|
||||||
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4=
|
||||||
@@ -1032,6 +1107,8 @@ github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6Mwd
|
|||||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 h1:JIAuq3EEf9cgbU6AtGPK4CTG3Zf6CKMNqf0MHTggAUA=
|
||||||
|
github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966/go.mod h1:sUM3LWHvSMaG192sy56D9F7CNvL7jUJVXoqM1QKLnog=
|
||||||
github.com/slackhq/nebula v1.6.1 h1:/OCTR3abj0Sbf2nGoLUrdDXImrCv0ZVFpVPP5qa0DsM=
|
github.com/slackhq/nebula v1.6.1 h1:/OCTR3abj0Sbf2nGoLUrdDXImrCv0ZVFpVPP5qa0DsM=
|
||||||
github.com/slackhq/nebula v1.6.1/go.mod h1:UmkqnXe4O53QwToSl/gG7sM4BroQwAB7dd4hUaT6MlI=
|
github.com/slackhq/nebula v1.6.1/go.mod h1:UmkqnXe4O53QwToSl/gG7sM4BroQwAB7dd4hUaT6MlI=
|
||||||
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
|
github.com/smallstep/assert v0.0.0-20200723003110-82e2b9b3b262 h1:unQFBIznI+VYD1/1fApl1A+9VcBk+9dcqGfnePY87LY=
|
||||||
@@ -1056,8 +1133,8 @@ github.com/smartystreets/goconvey v1.7.2/go.mod h1:Vw0tHAZW6lzCRk3xgdin6fKYcG+G3
|
|||||||
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
|
||||||
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
|
||||||
github.com/spdx/tools-golang v0.5.3 h1:ialnHeEYUC4+hkm5vJm4qz2x+oEJbS0mAMFrNXdQraY=
|
github.com/spdx/tools-golang v0.5.5 h1:61c0KLfAcNqAjlg6UNMdkwpMernhw3zVRwDZ2x9XOmk=
|
||||||
github.com/spdx/tools-golang v0.5.3/go.mod h1:/ETOahiAo96Ob0/RAIBmFZw6XN0yTnyr/uFZm2NTMhI=
|
github.com/spdx/tools-golang v0.5.5/go.mod h1:MVIsXx8ZZzaRWNQpUDhC4Dud34edUYJYecciXgrw5vE=
|
||||||
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
|
github.com/spf13/afero v1.1.2 h1:m8/z1t7/fwjysjQRYbP0RD+bUIF/8tJwPdEZsI83ACI=
|
||||||
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
|
||||||
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
|
github.com/spf13/cast v0.0.0-20150508191742-4d07383ffe94/go.mod h1:r2rcYCSwa1IExKTDiTfzaxqT2FNHs8hODu4LnUfgKEg=
|
||||||
@@ -1067,15 +1144,16 @@ github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w=
|
|||||||
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
|
||||||
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
github.com/spf13/cobra v0.0.5/go.mod h1:3K3wKZymM7VvHMDS9+Akkh4K60UwM26emMESw8tLCHU=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||||
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
|
||||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
|
||||||
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
|
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
|
||||||
github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
|
github.com/spf13/viper v1.3.2 h1:VUFqw5KcqRf7i70GOzW7N+Q7+gxVBkSSqiXB12+JQ4M=
|
||||||
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
github.com/spf13/viper v1.3.2/go.mod h1:ZiWeW+zYFKm7srdB9IoDzzZXaJaI5eL9QjNiN/DMA2s=
|
||||||
@@ -1098,19 +1176,21 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
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/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 h1:pV0H+XIvFoP7pl1MRtyPXh5hqoxB5I7snOtTHgrn6HU=
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933 h1:pV0H+XIvFoP7pl1MRtyPXh5hqoxB5I7snOtTHgrn6HU=
|
||||||
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU=
|
github.com/tailscale/tscert v0.0.0-20240517230440-bbccfbf48933/go.mod h1:kNGUQ3VESx3VZwRwA9MSCUegIl6+saPL8Noq82ozCaU=
|
||||||
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
|
github.com/theupdateframework/notary v0.7.0 h1:QyagRZ7wlSpjT5N2qQAh/pN+DVqgekv4DzbAiAiEL3c=
|
||||||
github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
|
github.com/theupdateframework/notary v0.7.0/go.mod h1:c9DRxcmhHmVLDay4/2fUYdISnHqbFDGRSlXPO0AhYWw=
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205 h1:eUk79E1w8yMtXeHSzjKorxuC8qJOnyXQnLaJehxpJaI=
|
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375 h1:QB54BJwA6x8QU9nHY3xJSZR2kX9bgpZekRKGkLTmEXA=
|
||||||
github.com/tonistiigi/dchapes-mode v0.0.0-20241001053921-ca0759fec205/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY=
|
github.com/tilt-dev/fsnotify v1.4.8-0.20220602155310-fff9c274a375/go.mod h1:xRroudyp5iVtxKqZCrA6n2TLFRBf8bmnjr1UD4x+z7g=
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c h1:bQLsfX+uEPZUjyR2qmoJs5F8Z57bPVmF3IsUf22Xo9Y=
|
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323 h1:r0p7fK56l8WPequOaR3i9LBqfPtEdXIQbUTzT55iqT4=
|
||||||
github.com/tonistiigi/fsutil v0.0.0-20241028165955-397af5306b5c/go.mod h1:Dl/9oEjK7IqnjAm21Okx/XIxUCFJzvh+XdVHUlBwXTw=
|
github.com/tonistiigi/dchapes-mode v0.0.0-20250318174251-73d941a28323/go.mod h1:3Iuxbr0P7D3zUzBMAZB+ois3h/et0shEz0qApgHYGpY=
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4 h1:7I5c2Ig/5FgqkYOh/N87NzoyI9U15qUPXhDD8uCupv8=
|
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f h1:MoxeMfHAe5Qj/ySSBfL8A7l1V+hxuluj8owsIEEZipI=
|
||||||
github.com/tonistiigi/go-csvvalue v0.0.0-20240710180619-ddb21b71c0b4/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
|
github.com/tonistiigi/fsutil v0.0.0-20250605211040-586307ad452f/go.mod h1:BKdcez7BiVtBvIcef90ZPc6ebqIWr4JWD7+EvLm6J98=
|
||||||
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0 h1:2f304B10LaZdB8kkVEaoXvAMVan2tl9AiK4G0odjQtE=
|
||||||
|
github.com/tonistiigi/go-csvvalue v0.0.0-20240814133006-030d3b2625d0/go.mod h1:278M4p8WsNh3n4a1eqiFcV2FGk7wE5fwUpUom9mK9lE=
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea h1:SXhTLE6pb6eld/v/cCndK0AMpt1wiVFb/YYmqB3/QG0=
|
||||||
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk=
|
github.com/tonistiigi/units v0.0.0-20180711220420-6950e57a87ea/go.mod h1:WPnis/6cRcDZSUvVmezrxJPkiO87ThFYsoUiMwWNDJk=
|
||||||
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw=
|
github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab h1:H6aJ0yKQ0gF49Qb2z5hI1UHxSQt4JMyxebFR15KnApw=
|
||||||
@@ -1118,28 +1198,22 @@ github.com/tonistiigi/vt100 v0.0.0-20240514184818-90bafcd6abab/go.mod h1:ulncasL
|
|||||||
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
github.com/tv42/httpunix v0.0.0-20150427012821-b75d8614f926/go.mod h1:9ESjWnEqriFuLhtthL60Sar/7RFoluCcXsuvEwTV5KM=
|
||||||
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
github.com/ugorji/go/codec v0.0.0-20181204163529-d75b2dcb6bc8/go.mod h1:VFNgLljTbGfSG7qAOspJ7OScBnGdDN/yBr0sguwnwf0=
|
||||||
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
github.com/urfave/cli v1.22.10/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
|
||||||
github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ=
|
github.com/urfave/cli v1.22.17 h1:SYzXoiPfQjHBbkYxbew5prZHS1TOLT3ierW8SYLqtVQ=
|
||||||
github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po=
|
github.com/urfave/cli v1.22.17/go.mod h1:b0ht0aqgH/6pBYzzxURyrM4xXNgsoT/n2ZzwQiEhNVo=
|
||||||
github.com/vbatts/tar-split v0.11.6 h1:4SjTW5+PU11n6fZenf2IPoV8/tz3AaYHMWjf23envGs=
|
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||||
github.com/vbatts/tar-split v0.11.6/go.mod h1:dqKNtesIOr2j2Qv3W/cHjnvk9I8+G7oAkFDFN6TCBEI=
|
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||||
github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8 h1:Y4egeTrP7sccowz2GWTJVtHlwkZippgBTpUmMteFUWQ=
|
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||||
github.com/vishvananda/netlink v1.3.1-0.20250303224720-0e7078ed04c8/go.mod h1:i6NetklAujEcC6fK0JPjT8qSwWyO0HLn4UKG+hGqeJs=
|
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||||
github.com/vishvananda/netns v0.0.4/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
|
||||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||||
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
|
github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s=
|
||||||
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
|
github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y=
|
||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ=
|
||||||
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0/go.mod h1:x6AKhvSSexNrVSrViXSHUEbICjmGXhtgABaHIySUSGw=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b h1:FsyNrX12e5BkplJq7wKOLk0+C6LZ+KGXvuEcKUYm5ss=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/weppos/publicsuffix-go v0.15.1-0.20210511084619-b1f36a2d6c0b/go.mod h1:HYux0V0Zi04bHNwOHy4cXJVz/TQjYonnF6aoYhj+3QE=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
github.com/xhit/go-str2duration/v2 v2.1.0 h1:lxklc02Drh6ynqX+DdPyp5pCKLUQpRT8bp8Ydu2Bstc=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
|
github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU=
|
||||||
github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
|
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 h1:EzJWgHovont7NscjpAxXsDA8S8BMYve8Y5+7cuRE7R0=
|
|
||||||
github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ=
|
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
|
|
||||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
|
|
||||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
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=
|
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
|
||||||
@@ -1147,6 +1221,8 @@ github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
|
|||||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0=
|
||||||
|
github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U=
|
||||||
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
github.com/zeebo/assert v1.1.0 h1:hU1L1vLTHsnO8x8c9KAR5GmM5QscxHg5RNU5z5qbUWY=
|
||||||
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
github.com/zeebo/assert v1.1.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||||
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
github.com/zeebo/blake3 v0.2.4 h1:KYQPkhpRtcqh0ssGYcKLG1JYvddkEA8QwCM/yBqhaZI=
|
||||||
@@ -1154,12 +1230,8 @@ github.com/zeebo/blake3 v0.2.4/go.mod h1:7eeQ6d2iXWRGF6npfaxl2CU+xy2Fjo2gxeyZGCR
|
|||||||
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
github.com/zeebo/pcg v1.0.1 h1:lyqfGeWiv4ahac6ttHs+I5hwtH/+1mrhlCtVNQM2kHo=
|
||||||
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
github.com/zeebo/pcg v1.0.1/go.mod h1:09F0S9iiKrwn9rlI5yjLkmrug154/YRW6KnnXVDM/l4=
|
||||||
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
github.com/zenazn/goji v0.9.0/go.mod h1:7S9M489iMyHBNxwZnk9/EHS098H4/F6TATF2mIxtB1Q=
|
||||||
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc h1:zkGwegkOW709y0oiAraH/3D8njopUR/pARHv4tZZ6pw=
|
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
|
||||||
github.com/zmap/zcrypto v0.0.0-20210511125630-18f1e0152cfc/go.mod h1:FM4U1E3NzlNMRnSUTU3P1UdukWhYGifqEsjk9fn7BCk=
|
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
|
||||||
github.com/zmap/zlint/v3 v3.1.0 h1:WjVytZo79m/L1+/Mlphl09WBob6YTGljN5IGWZFpAv0=
|
|
||||||
github.com/zmap/zlint/v3 v3.1.0/go.mod h1:L7t8s3sEKkb0A2BxGy1IWrxt1ZATa1R4QfJZaQOD3zU=
|
|
||||||
go.etcd.io/bbolt v1.4.0 h1:TU77id3TnN/zKr7CO/uk+fBCwF2jGcMuw2B/FMAzYIk=
|
|
||||||
go.etcd.io/bbolt v1.4.0/go.mod h1:AsD+OCi/qPN1giOX1aiLAha3o1U8rAz65bvN4j0sRuk=
|
|
||||||
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
|
||||||
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
@@ -1171,20 +1243,20 @@ go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQ
|
|||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0 h1:x7wzEgXfnzJcHDwStJT+mxOz4etr2EcexjqhBvmoakw=
|
||||||
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.60.0/go.mod h1:rg+RlpR5dKwaS95IyyZqj5Wd4E13lk/msnTS0Xl9lJM=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1 h1:gbhw/u49SS3gkPWiYweQNJGm/uJN5GkI/FrosxSHT7A=
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0 h1:0tY123n7CdWMem7MOVdKOt0YfshufLCwfE5Bob+hQuM=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.46.1/go.mod h1:GnOaBaFQ2we3b9AGWJpsBa7v1S5RlQzlC3O7dRMxZhM=
|
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.60.0/go.mod h1:CosX/aS4eHnG9D7nESYpV753l4j9q5j3SL/PUYd2lR8=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
|
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/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.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
|
||||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8=
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
|
||||||
@@ -1201,16 +1273,16 @@ go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsu
|
|||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s=
|
||||||
go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk=
|
go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk=
|
||||||
go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8=
|
go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
|
go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
|
go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFwHX4dThiPis=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||||
go.step.sm/cli-utils v0.9.0 h1:55jYcsQbnArNqepZyAwcato6Zy2MoZDRkWW+jF+aPfQ=
|
go.step.sm/cli-utils v0.9.0 h1:55jYcsQbnArNqepZyAwcato6Zy2MoZDRkWW+jF+aPfQ=
|
||||||
@@ -1233,8 +1305,8 @@ go.uber.org/fx v1.22.1/go.mod h1:HT2M7d7RHo+ebKGh9NRcrsrHHfpZ60nW3QRubMRfv48=
|
|||||||
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
go.uber.org/goleak v1.1.11-0.20210813005559-691160354723/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
|
||||||
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4=
|
||||||
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU=
|
||||||
@@ -1250,6 +1322,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
|||||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||||
go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs=
|
go.uber.org/zap/exp v0.2.0 h1:FtGenNNeCATRB3CmB/yEUnjEFeJWpB/pMcy7e2bKPYs=
|
||||||
go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ=
|
go.uber.org/zap/exp v0.2.0/go.mod h1:t0gqAIdh1MfKv9EwN/dLwfZnJxe9ITAZN78HEWPFWDQ=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
|
||||||
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
|
||||||
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||||
@@ -1276,13 +1350,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
|||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.30.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 h1:TgSqweA595vD0Zt86JzLv3Pb/syKg8gd5KMGGbJPYFw=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595 h1:TgSqweA595vD0Zt86JzLv3Pb/syKg8gd5KMGGbJPYFw=
|
||||||
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
|
golang.org/x/crypto/x509roots/fallback v0.0.0-20240507223354-67b13616a595/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e h1:4qufH0hlUYs6AO6XmZC3GqfDPGSXHVXUFR6OND+iJX4=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||||
golang.org/x/exp v0.0.0-20241215155358-4a5509556b9e/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
|
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
@@ -1297,8 +1371,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
|||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
@@ -1323,11 +1397,11 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.28.0 h1:CrgCKl8PPAVtLnU3c+EDw6x11699EWlsDeWNWKdIOkc=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
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-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -1342,8 +1416,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
@@ -1378,6 +1452,7 @@ golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -1407,8 +1482,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
golang.org/x/term v0.34.0 h1:O/2T7POpk0ZZ7MAzMeWFSg6S5IpWd/RXDlM9hgM3DR4=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
golang.org/x/term v0.34.0/go.mod h1:5jC53AEywhIVebHgPVeg0mj8OD3VO9OzclacVrqpaAw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
@@ -1422,10 +1497,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
|
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
|
||||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
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-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-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
@@ -1447,8 +1522,8 @@ 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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190410155217-1f06c39b4373/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190513163551-3ee3066db522/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
@@ -1471,10 +1546,10 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98
|
|||||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1 h1:BulPr26Jqjnd4eYDVe+YvyR7Yc2vJGkO5/0UxD0/jZU=
|
||||||
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
google.golang.org/genproto v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:hL97c3SYopEHblzpxRL4lSs523++l8DYxGM1FQiYmb4=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a h1:SGktgSolFCo75dnHJF2yMvnns6jCmHFJ0vE4Vn2JKvQ=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a h1:v2PbRU4K3llS09c7zodFpNePeamkAwG3mPrAery9VeE=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
|
||||||
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
|
||||||
@@ -1482,8 +1557,8 @@ google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyac
|
|||||||
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
google.golang.org/grpc v1.72.0 h1:S7UkcVa60b5AAQTaO6ZKamFp1zMZSU0fGDK2WZLbBnM=
|
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
|
||||||
google.golang.org/grpc v1.72.0/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
|
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
|
||||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
@@ -1493,8 +1568,8 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
|
|||||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
|
||||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
|
||||||
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
|
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
|
||||||
@@ -1506,9 +1581,15 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
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/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||||
|
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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
|
gopkg.in/gemnasium/logrus-airbrake-hook.v2 v2.1.2/go.mod h1:Xk6kEKp8OKb+X14hQBKWaSkCsqBpgog8nAV2xsGOxlo=
|
||||||
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s=
|
||||||
|
gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc=
|
||||||
|
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||||
|
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
||||||
|
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1 h1:d4KQkxAaAiRY2h5Zqis161Pv91A37uZyJOx73duwUwM=
|
gopkg.in/rethinkdb/rethinkdb-go.v6 v6.2.1 h1:d4KQkxAaAiRY2h5Zqis161Pv91A37uZyJOx73duwUwM=
|
||||||
@@ -1527,8 +1608,8 @@ 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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
|
||||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
|
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259 h1:TbRPT0HtzFP3Cno1zZo7yPzEEnfu8EjLfl6IU9VfqkQ=
|
||||||
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
|
gvisor.dev/gvisor v0.0.0-20230927004350-cbd86285d259/go.mod h1:AVgIgHMwK63XvmAzWG9vLQ41YnVHN0du0tEC46fI7yY=
|
||||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
@@ -1536,8 +1617,18 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh
|
|||||||
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
|
||||||
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
|
||||||
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
|
k8s.io/api v0.32.3 h1:Hw7KqxRusq+6QSplE3NYG4MBxZw1BZnq4aP4cJVINls=
|
||||||
|
k8s.io/api v0.32.3/go.mod h1:2wEDTXADtm/HA7CCMD8D8bK4yuBUptzaRhYcYEEYA3k=
|
||||||
|
k8s.io/apimachinery v0.32.3 h1:JmDuDarhDmA/Li7j3aPrwhpNBA94Nvk5zLeOge9HH1U=
|
||||||
|
k8s.io/apimachinery v0.32.3/go.mod h1:GpHVgxoKlTxClKcteaeuF1Ul/lDVb74KpZcxcmLDElE=
|
||||||
|
k8s.io/client-go v0.32.3 h1:RKPVltzopkSgHS7aS98QdscAgtgah/+zmpAogooIqVU=
|
||||||
|
k8s.io/client-go v0.32.3/go.mod h1:3v0+3k4IcT9bXTc4V2rt+d2ZPPG700Xy6Oi0Gdl2PaY=
|
||||||
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk=
|
||||||
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f h1:GA7//TjRY9yWGy1poLzYYJJ4JRdzg3+O6e8I+e+8T5Y=
|
||||||
|
k8s.io/kube-openapi v0.0.0-20241105132330-32ad38e42d3f/go.mod h1:R/HEjbvWI0qdfb8viZUeVZm0X6IZnxAydC7YU42CMw4=
|
||||||
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738 h1:M3sRQVHv7vB20Xc2ybTt7ODCeFj6JSWYFzOFnYeS6Ro=
|
||||||
|
k8s.io/utils v0.0.0-20241104100929-3ea5e8cea738/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
|
||||||
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
lukechampine.com/blake3 v1.3.0 h1:sJ3XhFINmHSrYCgl958hscfIa3bw8x4DqMP3u1YvoYE=
|
||||||
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
lukechampine.com/blake3 v1.3.0/go.mod h1:0OFRp7fBtAylGVCO40o87sbupkyIGgbpv1+M1k1LM6k=
|
||||||
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
modernc.org/cc/v4 v4.24.4 h1:TFkx1s6dCkQpd6dKurBNmpo+G8Zl4Sq/ztJ+2+DEsh0=
|
||||||
@@ -1564,3 +1655,11 @@ modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
|||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3 h1:/Rv+M11QRah1itp8VhT6HoVx1Ray9eB4DBr+K+/sCJ8=
|
||||||
|
sigs.k8s.io/json v0.0.0-20241010143419-9aa6b5e7a4b3/go.mod h1:18nIHnGi6636UCz6m8i4DhaJ65T6EruyzmoQqI2BVDo=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.2 h1:MdmvkGuXi/8io6ixD5wud3vOLwc1rj0aNqRlpuvjmwA=
|
||||||
|
sigs.k8s.io/structured-merge-diff/v4 v4.4.2/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4=
|
||||||
|
sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E=
|
||||||
|
sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY=
|
||||||
|
tags.cncf.io/container-device-interface v1.0.1 h1:KqQDr4vIlxwfYh0Ed/uJGVgX+CHAkahrgabg6Q8GYxc=
|
||||||
|
tags.cncf.io/container-device-interface v1.0.1/go.mod h1:JojJIOeW3hNbcnOH2q0NrWNha/JuHoDZcmYxAZwb2i0=
|
||||||
|
|||||||
+179
-154
@@ -2,175 +2,200 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"errors"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/distribution/reference"
|
mapset "github.com/deckarep/golang-set/v2"
|
||||||
"github.com/docker/cli/cli/config"
|
"github.com/docker/cli/cli/command"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/cli/cli/flags"
|
||||||
"github.com/docker/docker/api/types/image"
|
composeapi "github.com/docker/compose/v2/pkg/api"
|
||||||
dockerclient "github.com/docker/docker/client"
|
composev2 "github.com/docker/compose/v2/pkg/compose"
|
||||||
"github.com/docker/docker/pkg/archive"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
"github.com/docker/docker/registry"
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
"github.com/moby/term"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type BuildOptions struct {
|
// BuildServicesOptions contains options for building services in a Compose project.
|
||||||
Files []string
|
type BuildServicesOptions struct {
|
||||||
Profiles []string
|
// BuildArgs sets build-time variables for services. Used in Dockerfiles that declare variables with ARG.
|
||||||
|
BuildArgs []string
|
||||||
|
// Check the build configuration for services without building them.
|
||||||
|
Check bool
|
||||||
|
// Deps enables to also build services declared as dependencies of the selected Services.
|
||||||
|
Deps bool
|
||||||
|
// NoCache disables the use of cache when building images.
|
||||||
|
NoCache bool
|
||||||
|
// Pull attempts to pull newer versions of the base images before building.
|
||||||
|
Pull bool
|
||||||
|
// Services specifies which services to build. If empty, all services with a build config are built.
|
||||||
Services []string
|
Services []string
|
||||||
Push bool
|
|
||||||
NoCache bool
|
// Push targets are mutually exclusive.
|
||||||
|
// PushCluster uploads the built images to cluster machines after building.
|
||||||
|
PushCluster bool
|
||||||
|
// PushRegistry uploads the built images to external registries after building.
|
||||||
|
PushRegistry bool
|
||||||
|
|
||||||
|
// Cluster-specific options (only used if PushCluster is true).
|
||||||
|
// Context is the name of the cluster context.
|
||||||
|
Context string
|
||||||
|
// Machines is a list of machine names or IDs to push the image to. If empty, images are pushed to all machines.
|
||||||
|
Machines []string
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetServicesThatNeedBuild returns a map of services that require building
|
// BuildServices builds images for services in the Compose project.
|
||||||
func GetServicesThatNeedBuild(project *composetypes.Project) map[string]composetypes.ServiceConfig {
|
func (cli *CLI) BuildServices(ctx context.Context, project *composetypes.Project, opts BuildServicesOptions) error {
|
||||||
servicesToBuild := make(map[string]composetypes.ServiceConfig, len(project.Services))
|
// Validate push targets.
|
||||||
for serviceName, service := range project.Services {
|
if opts.PushCluster && opts.PushRegistry {
|
||||||
if service.Build == nil {
|
return fmt.Errorf("cannot specify both PushCluster and PushRegistry: choose one push target")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build service images using Compose implementation.
|
||||||
|
dockerCli, err := command.NewDockerCli()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create docker client: %w", err)
|
||||||
|
}
|
||||||
|
// Initialise the Docker CLI with default options.
|
||||||
|
if err = dockerCli.Initialize(flags.NewClientOptions()); err != nil {
|
||||||
|
return fmt.Errorf("initialise docker client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
composeService := composev2.NewComposeService(dockerCli)
|
||||||
|
buildOpts := composeapi.BuildOptions{
|
||||||
|
Args: composetypes.NewMappingWithEquals(opts.BuildArgs),
|
||||||
|
Check: opts.Check,
|
||||||
|
Deps: opts.Deps,
|
||||||
|
NoCache: opts.NoCache,
|
||||||
|
Pull: opts.Pull,
|
||||||
|
Push: opts.PushRegistry,
|
||||||
|
Services: opts.Services,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = composeService.Build(ctx, project, buildOpts); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if !opts.PushCluster {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push built service images to cluster machines.
|
||||||
|
builtServices, err := ServicesThatNeedBuild(project, opts.Services, opts.Deps)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("determine built services: %w", err)
|
||||||
|
}
|
||||||
|
if len(builtServices) == 0 {
|
||||||
|
// No services were built, nothing to push.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a line break after the build output.
|
||||||
|
fmt.Fprintln(cli.ProgressOut())
|
||||||
|
|
||||||
|
clusterClient, err := cli.ConnectCluster(ctx, opts.Context)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("connect to cluster: %w", err)
|
||||||
|
}
|
||||||
|
defer clusterClient.Close()
|
||||||
|
|
||||||
|
// Push one service image at a time.
|
||||||
|
var errs []error
|
||||||
|
for _, s := range builtServices {
|
||||||
|
if s.Image == "" {
|
||||||
|
// Skip services without an image name (shouldn't happen for services with build config).
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
servicesToBuild[serviceName] = service
|
|
||||||
}
|
|
||||||
return servicesToBuild
|
|
||||||
}
|
|
||||||
|
|
||||||
// BuildServices builds the services defined in the provided map.
|
// Push to the specified machines falling back to service x-machines.
|
||||||
func BuildServices(ctx context.Context, servicesToBuild map[string]composetypes.ServiceConfig, opts BuildOptions) error {
|
// If none specified, push to *all* cluster machines.
|
||||||
fmt.Println("Building services...")
|
var pushOpts client.PushImageOptions
|
||||||
|
|
||||||
// Init docker client (can be local or remote, depending on DOCKER_HOST environment variable)
|
if len(opts.Machines) > 0 {
|
||||||
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
pushOpts.Machines = opts.Machines
|
||||||
if err != nil {
|
} else if machines, ok := s.Extensions[compose.MachinesExtensionKey].(compose.MachinesSource); ok {
|
||||||
return err
|
pushOpts.Machines = machines
|
||||||
}
|
}
|
||||||
defer dockerCli.Close()
|
|
||||||
|
|
||||||
serviceImages := make(map[string]string, len(servicesToBuild))
|
if len(pushOpts.Machines) == 0 {
|
||||||
|
pushOpts.AllMachines = true
|
||||||
|
}
|
||||||
|
|
||||||
// Build the services using the local docker client and compose libraries
|
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||||
for _, service := range servicesToBuild {
|
err = progress.RunWithTitle(ctx, func(ctx context.Context) error {
|
||||||
fmt.Printf("Building service: %s\n", service.Name)
|
if err = clusterClient.PushImage(ctx, s.Image, pushOpts); err != nil {
|
||||||
imageName, err := buildSingleService(ctx, dockerCli, service, opts)
|
return fmt.Errorf("push image '%s' for service '%s': %w", s.Image, s.Name, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, cli.ProgressOut(), fmt.Sprintf("Pushing image %s to cluster", boldStyle.Render(s.Image)))
|
||||||
|
// Collect errors to try pushing all images.
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("build service %s: %w", service.Name, err)
|
errs = append(errs, err)
|
||||||
}
|
|
||||||
serviceImages[service.Name] = imageName
|
|
||||||
}
|
|
||||||
fmt.Printf("Service images are built.\n")
|
|
||||||
|
|
||||||
if opts.Push {
|
|
||||||
err = pushServiceImages(ctx, dockerCli, serviceImages)
|
|
||||||
}
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildSingleService builds a single service using the Docker client and Compose libraries.
|
|
||||||
func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, service composetypes.ServiceConfig, opts BuildOptions) (string, error) {
|
|
||||||
if service.Build == nil {
|
|
||||||
return "", fmt.Errorf("service %s has no build configuration", service.Name)
|
|
||||||
}
|
|
||||||
if service.Image == "" {
|
|
||||||
return "", fmt.Errorf("service %s has no image specified; building services without image is not supported yet", service.Name)
|
|
||||||
}
|
|
||||||
|
|
||||||
buildContextPath := service.Build.Context
|
|
||||||
imageName := service.Image
|
|
||||||
|
|
||||||
// Create a tar archive of the build context
|
|
||||||
buildContext, err := archive.TarWithOptions(buildContextPath, &archive.TarOptions{})
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to create build context for service %s: %w", service.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
buildOptions := types.ImageBuildOptions{
|
|
||||||
// TODO: Support Dockerfiles outside the build context
|
|
||||||
// See https://github.com/docker/compose/blob/cf89fd1aa1328d5af77658ccc5a1e1b29981ae80/pkg/compose/build_classic.go#L92
|
|
||||||
Dockerfile: service.Build.Dockerfile,
|
|
||||||
Tags: []string{imageName},
|
|
||||||
Remove: true, // Remove intermediate containers
|
|
||||||
NoCache: opts.NoCache,
|
|
||||||
}
|
|
||||||
|
|
||||||
buildResponse, err := dockerCli.ImageBuild(ctx, buildContext, buildOptions)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to build image for service %s: %w", service.Name, err)
|
|
||||||
}
|
|
||||||
defer buildResponse.Body.Close()
|
|
||||||
|
|
||||||
// Display the build response
|
|
||||||
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
|
||||||
if err := jsonmessage.DisplayJSONMessagesStream(buildResponse.Body, os.Stdout, fd, isTerminal, nil); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to display build response for service %s: %w", service.Name, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return imageName, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pushSingleServiceImage pushes a single service image.
|
|
||||||
func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client, serviceName string, imageName string) error {
|
|
||||||
ref, err := reference.ParseNormalizedNamed(imageName)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
repoInfo, err := registry.ParseRepositoryInfo(ref)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
registryKey := repoInfo.Index.Name
|
|
||||||
if repoInfo.Index.Official {
|
|
||||||
registryKey = registry.IndexServer
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the Docker config file with auth details, if available
|
|
||||||
configFile := config.LoadDefaultConfigFile(os.Stderr)
|
|
||||||
|
|
||||||
authConfig, err := configFile.GetAuthConfig(registryKey)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
authJSON, err := json.Marshal(authConfig)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to marshal auth config for registry %s: %w", registryKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
authStr := base64.URLEncoding.EncodeToString(authJSON)
|
|
||||||
|
|
||||||
pushOptions := image.PushOptions{
|
|
||||||
RegistryAuth: authStr,
|
|
||||||
}
|
|
||||||
pushResponse, err := dockerCli.ImagePush(ctx, imageName, pushOptions)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to push image %s: %w", imageName, err)
|
|
||||||
}
|
|
||||||
defer pushResponse.Close()
|
|
||||||
|
|
||||||
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
|
|
||||||
|
|
||||||
fd, isTerminal := term.GetFdInfo(os.Stdout)
|
|
||||||
if err := jsonmessage.DisplayJSONMessagesStream(pushResponse, os.Stdout, fd, isTerminal, nil); err != nil {
|
|
||||||
return fmt.Errorf("failed to display push response for image %s: %w", imageName, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
fmt.Printf("Image %s pushed successfully.\n", imageName)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// pushServiceImages pushes all built service images to the registry.
|
|
||||||
func pushServiceImages(ctx context.Context, dockerCli *dockerclient.Client, serviceImages map[string]string) error {
|
|
||||||
fmt.Printf("Pushing images...\n")
|
|
||||||
for serviceName, imageName := range serviceImages {
|
|
||||||
if err := pushSingleServiceImage(ctx, dockerCli, serviceName, imageName); err != nil {
|
|
||||||
return fmt.Errorf("push image for service %s: %w", serviceName, err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServicesThatNeedBuild returns a list of services that require building.
|
||||||
|
// deps indicates whether to include services that are dependencies of the selected services.
|
||||||
|
// Implementation is based on the logic from docker/compose/v2/pkg/compose/build.go.
|
||||||
|
func ServicesThatNeedBuild(
|
||||||
|
project *composetypes.Project, selectedServices []string, deps bool,
|
||||||
|
) ([]composetypes.ServiceConfig, error) {
|
||||||
|
servicesToBuild := make([]composetypes.ServiceConfig, 0, len(project.Services))
|
||||||
|
|
||||||
|
var policy composetypes.DependencyOption = composetypes.IgnoreDependencies
|
||||||
|
if deps {
|
||||||
|
policy = composetypes.IncludeDependencies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also include services used as build.additional_contexts with service: prefix.
|
||||||
|
selectedServices = includeAdditionalContextsServices(project, selectedServices)
|
||||||
|
// Some build dependencies we just introduced may not be enabled, enable them.
|
||||||
|
var err error
|
||||||
|
if project, err = project.WithServicesEnabled(selectedServices...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if project, err = project.WithSelectedServices(selectedServices); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = project.ForEachService(selectedServices, func(serviceName string, service *composetypes.ServiceConfig) error {
|
||||||
|
if service.Build != nil {
|
||||||
|
servicesToBuild = append(servicesToBuild, *service)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}, policy)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesToBuild, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// includeAdditionalContextsServices adds services referenced in build.additional_contexts to the list
|
||||||
|
// of selected services.
|
||||||
|
func includeAdditionalContextsServices(project *composetypes.Project, selectedServices []string) []string {
|
||||||
|
servicesWithDependencies := mapset.NewSet(selectedServices...)
|
||||||
|
for _, service := range selectedServices {
|
||||||
|
s, ok := project.Services[service]
|
||||||
|
if !ok {
|
||||||
|
s = project.DisabledServices[service]
|
||||||
|
}
|
||||||
|
if s.Build != nil {
|
||||||
|
for _, target := range s.Build.AdditionalContexts {
|
||||||
|
if name, found := strings.CutPrefix(target, composetypes.ServicePrefix); found {
|
||||||
|
servicesWithDependencies.Add(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if servicesWithDependencies.Cardinality() > len(selectedServices) {
|
||||||
|
return includeAdditionalContextsServices(project, servicesWithDependencies.ToSlice())
|
||||||
|
}
|
||||||
|
|
||||||
|
return servicesWithDependencies.ToSlice()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package config
|
package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
@@ -17,6 +18,7 @@ const (
|
|||||||
|
|
||||||
type MachineConnection struct {
|
type MachineConnection struct {
|
||||||
SSH SSHDestination `yaml:"ssh,omitempty"`
|
SSH SSHDestination `yaml:"ssh,omitempty"`
|
||||||
|
SSHCLI SSHDestination `yaml:"ssh_cli,omitempty"`
|
||||||
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
SSHKeyFile string `yaml:"ssh_key_file,omitempty"`
|
||||||
// TCP is the address and port of the machine's API server.
|
// TCP is the address and port of the machine's API server.
|
||||||
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
// The pointer is used to omit the field when not set. Otherwise, yaml marshalling includes an empty object.
|
||||||
@@ -27,13 +29,37 @@ type MachineConnection struct {
|
|||||||
|
|
||||||
func (c MachineConnection) String() string {
|
func (c MachineConnection) String() string {
|
||||||
if c.SSH != "" {
|
if c.SSH != "" {
|
||||||
return string(c.SSH)
|
return "ssh://" + string(c.SSH)
|
||||||
|
} else if c.SSHCLI != "" {
|
||||||
|
return "ssh+cli://" + string(c.SSHCLI)
|
||||||
} else if c.TCP != nil && c.TCP.IsValid() {
|
} else if c.TCP != nil && c.TCP.IsValid() {
|
||||||
return fmt.Sprintf("tcp://%s", c.TCP)
|
return fmt.Sprintf("tcp://%s", c.TCP)
|
||||||
}
|
}
|
||||||
return "unknown connection"
|
return "unknown connection"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *MachineConnection) Validate() error {
|
||||||
|
setCount := 0
|
||||||
|
if c.SSH != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
if c.SSHCLI != "" {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
if c.TCP != nil && c.TCP.IsValid() {
|
||||||
|
setCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if setCount == 0 {
|
||||||
|
return errors.New("no connection method specified (ssh, ssh_cli, or tcp required)")
|
||||||
|
}
|
||||||
|
if setCount > 1 {
|
||||||
|
return errors.New("only one connection method allowed per connection (ssh, ssh_cli, or tcp)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
// SSHDestination represents an SSH destination string in the canonical form of "user@host:port".
|
||||||
// The default user "root" and port 22 can be omitted.
|
// The default user "root" and port 22 can be omitted.
|
||||||
type SSHDestination string
|
type SSHDestination string
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMachineConnection_String(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
conn MachineConnection
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ssh connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com",
|
||||||
|
},
|
||||||
|
want: "ssh://user@host.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh connection with port",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host.com:2222",
|
||||||
|
},
|
||||||
|
want: "ssh://user@host.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host.com",
|
||||||
|
},
|
||||||
|
want: "ssh+cli://user@host.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli connection with port",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host.com:2222",
|
||||||
|
},
|
||||||
|
want: "ssh+cli://user@host.com:2222",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp connection",
|
||||||
|
conn: MachineConnection{
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
want: "tcp://10.0.0.1:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no connection",
|
||||||
|
conn: MachineConnection{},
|
||||||
|
want: "unknown connection",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
got := tt.conn.String()
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMachineConnection_Validate(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
conn MachineConnection
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "ssh only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tcp only - valid",
|
||||||
|
conn: MachineConnection{
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no connection method - error",
|
||||||
|
conn: MachineConnection{},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "no connection method specified",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and ssh_cli - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh and tcp - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ssh_cli and tcp - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSHCLI: "user@host",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all three - error",
|
||||||
|
conn: MachineConnection{
|
||||||
|
SSH: "user@host",
|
||||||
|
SSHCLI: "user@host2",
|
||||||
|
TCP: func() *netip.AddrPort {
|
||||||
|
addr := netip.MustParseAddrPort("10.0.0.1:8080")
|
||||||
|
return &addr
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "only one connection method allowed",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := tt.conn.Validate()
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.errMsg)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+38
-17
@@ -56,26 +56,47 @@ func connectClusterWithProgress(ctx context.Context, conn config.MachineConnecti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
func connectCluster(ctx context.Context, conn config.MachineConnection) (*client.Client, error) {
|
||||||
if conn.SSH != "" {
|
// Determine which SSH type is configured
|
||||||
user, host, port, err := conn.SSH.Parse()
|
var sshDest config.SSHDestination
|
||||||
if err != nil {
|
var useSSHCLI bool
|
||||||
return nil, fmt.Errorf("parse SSH connection %q: %w", conn.SSH, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
// Validate connection configuration early to provide clear error messages.
|
||||||
|
if err := conn.Validate(); err != nil {
|
||||||
sshConfig := &connector.SSHConnectorConfig{
|
return nil, fmt.Errorf("invalid connection configuration: %w", err)
|
||||||
User: user,
|
|
||||||
Host: host,
|
|
||||||
Port: port,
|
|
||||||
KeyPath: keyPath,
|
|
||||||
}
|
|
||||||
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
|
||||||
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
|
||||||
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("connection configuration is invalid")
|
if conn.SSH != "" {
|
||||||
|
sshDest = conn.SSH
|
||||||
|
useSSHCLI = false
|
||||||
|
} else if conn.SSHCLI != "" {
|
||||||
|
sshDest = conn.SSHCLI
|
||||||
|
useSSHCLI = true
|
||||||
|
} else if conn.TCP != nil && conn.TCP.IsValid() {
|
||||||
|
return client.New(ctx, connector.NewTCPConnector(*conn.TCP))
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("connection configuration is invalid")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse SSH destination and create config (shared for both types)
|
||||||
|
user, host, port, err := sshDest.Parse()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse SSH connection %q: %w", sshDest, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
keyPath := fs.ExpandHomeDir(conn.SSHKeyFile)
|
||||||
|
|
||||||
|
sshConfig := &connector.SSHConnectorConfig{
|
||||||
|
User: user,
|
||||||
|
Host: host,
|
||||||
|
Port: port,
|
||||||
|
KeyPath: keyPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create appropriate connector based on type
|
||||||
|
if useSSHCLI {
|
||||||
|
return client.New(ctx, connector.NewSSHCLIConnector(sshConfig))
|
||||||
|
}
|
||||||
|
return client.New(ctx, connector.NewSSHConnector(sshConfig))
|
||||||
}
|
}
|
||||||
|
|
||||||
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
// connectModel is a TUI model for connecting to a cluster with a progress spinner.
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ import (
|
|||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
type Client struct {
|
||||||
|
*client.Client
|
||||||
|
}
|
||||||
|
|
||||||
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
// WaitDaemonReady waits for the Docker daemon to start and be ready to serve requests.
|
||||||
|
// TODO(lhf): convert into a Client method.
|
||||||
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
func WaitDaemonReady(ctx context.Context, cli *client.Client) error {
|
||||||
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
// Retry to ping the Docker daemon until it's ready or the context is canceled.
|
||||||
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
boff := backoff.WithContext(backoff.NewExponentialBackOff(
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CreateContainerWithImagePull creates a container. If the image is missing, it pulls the image first.
|
||||||
|
func (cli *Client) CreateContainerWithImagePull(
|
||||||
|
ctx context.Context, name string, config *container.Config, hostConfig *container.HostConfig,
|
||||||
|
) (container.CreateResponse, error) {
|
||||||
|
resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
|
||||||
|
if err == nil || !errdefs.IsNotFound(err) {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
pullCh, err := cli.PullImage(ctx, config.Image, image.PullOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return resp, fmt.Errorf("pull image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drain the pull channel until it's closed (image is fully pulled) or an error occurs.
|
||||||
|
// If the context is canceled during the pull, the channel will receive a context cancellation error.
|
||||||
|
for msg := range pullCh {
|
||||||
|
if msg.Err != nil {
|
||||||
|
return resp, fmt.Errorf("pull image: %w", msg.Err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container again after image pull.
|
||||||
|
if resp, err = cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name); err != nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitPortPublished waits for a container port to be published on the host which happens asynchronously.
|
||||||
|
func (cli *Client) WaitPortPublished(ctx context.Context, containerID string, port nat.Port) ([]nat.PortBinding, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
for {
|
||||||
|
c, err := cli.ContainerInspect(ctx, containerID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
binding, ok := c.NetworkSettings.Ports[port]
|
||||||
|
if ok && len(binding) > 0 {
|
||||||
|
return binding, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
case <-ctx.Done():
|
||||||
|
if errors.Is(ctx.Err(), context.DeadlineExceeded) {
|
||||||
|
return nil, errors.New("timeout")
|
||||||
|
}
|
||||||
|
return nil, ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
|
dockerconfig "github.com/docker/cli/cli/config"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
|
"github.com/docker/docker/api/types/registry"
|
||||||
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PullPushImageMessage struct {
|
||||||
|
Message jsonmessage.JSONMessage
|
||||||
|
Err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullImage pulls an image and returns a channel to receive progress messages.
|
||||||
|
func (cli *Client) PullImage(
|
||||||
|
ctx context.Context, image string, opts image.PullOptions,
|
||||||
|
) (<-chan PullPushImageMessage, error) {
|
||||||
|
if opts.RegistryAuth == "" {
|
||||||
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
|
if encodedAuth, err := RetrieveLocalDockerRegistryAuth(image); err == nil {
|
||||||
|
opts.RegistryAuth = encodedAuth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := cli.ImagePull(ctx, image, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan PullPushImageMessage)
|
||||||
|
go processPullPushImageResp(ctx, respBody, ch)
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushImage pushes an image and returns a channel to receive progress messages.
|
||||||
|
func (cli *Client) PushImage(
|
||||||
|
ctx context.Context, image string, opts image.PushOptions,
|
||||||
|
) (<-chan PullPushImageMessage, error) {
|
||||||
|
if opts.RegistryAuth == "" {
|
||||||
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
|
encodedAuth, _ := RetrieveLocalDockerRegistryAuth(image)
|
||||||
|
if encodedAuth == "" {
|
||||||
|
// If no credentials are found, provide an encoded empty auth config to work around the bug in Docker:
|
||||||
|
// https://github.com/moby/moby/issues/50729
|
||||||
|
var err error
|
||||||
|
if encodedAuth, err = registry.EncodeAuthConfig(registry.AuthConfig{}); err != nil {
|
||||||
|
return nil, fmt.Errorf("encode empty auth config: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
opts.RegistryAuth = encodedAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
respBody, err := cli.ImagePush(ctx, image, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ch := make(chan PullPushImageMessage)
|
||||||
|
go processPullPushImageResp(ctx, respBody, ch)
|
||||||
|
|
||||||
|
return ch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processPullPushImageResp decodes JSON messages from the image pull/push response body and
|
||||||
|
// sends them to the provided channel.
|
||||||
|
func processPullPushImageResp(ctx context.Context, respBody io.ReadCloser, ch chan<- PullPushImageMessage) {
|
||||||
|
defer respBody.Close()
|
||||||
|
defer close(ch)
|
||||||
|
|
||||||
|
decoder := json.NewDecoder(respBody)
|
||||||
|
for {
|
||||||
|
var jm jsonmessage.JSONMessage
|
||||||
|
if err := decoder.Decode(&jm); err != nil {
|
||||||
|
if errors.Is(err, io.EOF) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ch <- PullPushImageMessage{Err: fmt.Errorf("decode image pull/push message: %w", err)}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := PullPushImageMessage{Message: jm}
|
||||||
|
if jm.Error != nil {
|
||||||
|
msg.Err = errors.New(jm.Error.Message)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
ch <- PullPushImageMessage{Err: ctx.Err()}
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
ch <- msg
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetrieveLocalDockerRegistryAuth retrieves the authentication token for the specified image from the local Docker
|
||||||
|
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
|
||||||
|
// no credentials are found.
|
||||||
|
func RetrieveLocalDockerRegistryAuth(image string) (string, error) {
|
||||||
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
|
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
||||||
|
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
|
||||||
|
// Return encodedAuth only if it contains any credentials.
|
||||||
|
auth, err := registry.DecodeAuthConfig(encodedAuth)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("decode auth config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.Username == "" &&
|
||||||
|
auth.Password == "" &&
|
||||||
|
auth.Auth == "" &&
|
||||||
|
auth.IdentityToken == "" &&
|
||||||
|
auth.RegistryToken == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return encodedAuth, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
package gitutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GitState contains information about the Git repository state.
|
||||||
|
type GitState struct {
|
||||||
|
// Date is the current commit datetime.
|
||||||
|
Date time.Time
|
||||||
|
// IsDirty indicates whether there are uncommitted changes.
|
||||||
|
IsDirty bool
|
||||||
|
// IsRepo indicates whether the directory is a Git repository.
|
||||||
|
IsRepo bool
|
||||||
|
// SHA is the full SHA-1 (40 characters) of the current commit.
|
||||||
|
SHA string
|
||||||
|
}
|
||||||
|
|
||||||
|
// InspectGitState inspects the Git repo state from the specified directory.
|
||||||
|
// If the directory is not a Git repository, or if git utility is not available,
|
||||||
|
// returns GitState with IsRepo=false and no error.
|
||||||
|
func InspectGitState(dir string) (GitState, error) {
|
||||||
|
// Check if git command is available.
|
||||||
|
if _, err := exec.LookPath("git"); err != nil {
|
||||||
|
return GitState{}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state := GitState{
|
||||||
|
IsRepo: isGitRepo(dir),
|
||||||
|
}
|
||||||
|
if !state.IsRepo {
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the current commit SHA.
|
||||||
|
sha, err := gitCommand(dir, "rev-parse", "HEAD")
|
||||||
|
if err != nil {
|
||||||
|
return state, fmt.Errorf("get current commit SHA: %w", err)
|
||||||
|
}
|
||||||
|
state.SHA = strings.TrimSpace(sha)
|
||||||
|
|
||||||
|
// Get the current commit timestamp.
|
||||||
|
timestamp, err := gitCommand(dir, "log", "-1", "--format=%ct")
|
||||||
|
if err != nil {
|
||||||
|
return state, fmt.Errorf("get current commit timestamp: %w", err)
|
||||||
|
}
|
||||||
|
seconds, err := strconv.ParseInt(strings.TrimSpace(timestamp), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return state, fmt.Errorf("parse current commit timestamp: %w", err)
|
||||||
|
}
|
||||||
|
state.Date = time.Unix(seconds, 0).UTC()
|
||||||
|
|
||||||
|
// Check for uncommitted changes.
|
||||||
|
status, err := gitCommand(dir, "status", "--porcelain")
|
||||||
|
if err != nil {
|
||||||
|
return state, fmt.Errorf("check git status: %w", err)
|
||||||
|
}
|
||||||
|
state.IsDirty = strings.TrimSpace(status) != ""
|
||||||
|
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isGitRepo checks if the directory is a Git repository.
|
||||||
|
func isGitRepo(dir string) bool {
|
||||||
|
_, err := gitCommand(dir, "rev-parse", "--git-dir")
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gitCommand runs a git command in the specified directory.
|
||||||
|
func gitCommand(dir string, args ...string) (string, error) {
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||||
|
return "", fmt.Errorf("git command failed: %s", exitErr.Stderr)
|
||||||
|
}
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(output), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShortSHA returns a truncated SHA with the specified length.
|
||||||
|
// If length is negative, zero, or greater than the SHA length, it returns the full SHA.
|
||||||
|
// Returns empty string if SHA is empty.
|
||||||
|
func (s *GitState) ShortSHA(length int) string {
|
||||||
|
if length <= 0 || length > len(s.SHA) {
|
||||||
|
length = len(s.SHA)
|
||||||
|
}
|
||||||
|
return s.SHA[:length]
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
package gitutil
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInspectGitState_GitNotAvailable(t *testing.T) {
|
||||||
|
// Reset the PATH env var to simulate git not being available.
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
state, err := InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.False(t, state.IsRepo)
|
||||||
|
assert.Empty(t, state.SHA)
|
||||||
|
assert.Empty(t, state.ShortSHA(7))
|
||||||
|
assert.True(t, state.Date.IsZero())
|
||||||
|
assert.False(t, state.IsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectGitState_NotARepo(t *testing.T) {
|
||||||
|
// Create a temporary directory that's not a git repo.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
|
||||||
|
state, err := InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// If tmpDir happens to be in a git repo (e.g., parent directory), skip this test.
|
||||||
|
// This is common when running tests from within the project itself.
|
||||||
|
if state.IsRepo {
|
||||||
|
t.Skip("tmpDir is within a git repository, skipping test")
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.False(t, state.IsRepo)
|
||||||
|
assert.Empty(t, state.SHA)
|
||||||
|
assert.Empty(t, state.ShortSHA(7))
|
||||||
|
assert.True(t, state.Date.IsZero()) // Should have zero time if not a repo.
|
||||||
|
assert.False(t, state.IsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectGitState_CleanRepo(t *testing.T) {
|
||||||
|
// Create a temporary git repo.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
initGitRepo(t, tmpDir)
|
||||||
|
|
||||||
|
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||||
|
|
||||||
|
state, err := InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, state.IsRepo)
|
||||||
|
assert.Len(t, state.SHA, 40)
|
||||||
|
assert.Equal(t, state.ShortSHA(7), state.SHA[:7])
|
||||||
|
assert.False(t, state.Date.IsZero())
|
||||||
|
assert.False(t, state.IsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectGitState_DirtyRepo(t *testing.T) {
|
||||||
|
// Create a temporary git repo.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
initGitRepo(t, tmpDir)
|
||||||
|
|
||||||
|
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||||
|
|
||||||
|
// Modify the file to make the repo dirty.
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("modified content"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state, err := InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, state.IsRepo)
|
||||||
|
assert.True(t, state.IsDirty)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInspectGitState_UntrackedFiles(t *testing.T) {
|
||||||
|
// Create a temporary git repo.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
initGitRepo(t, tmpDir)
|
||||||
|
|
||||||
|
createAndCommitFile(t, tmpDir, "test.txt", "test content")
|
||||||
|
|
||||||
|
// Add an untracked file.
|
||||||
|
err := os.WriteFile(filepath.Join(tmpDir, "untracked.txt"), []byte("untracked"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
state, err := InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.True(t, state.IsRepo)
|
||||||
|
assert.True(t, state.IsDirty) // Untracked files make the repo dirty.
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitState_ShortSHA(t *testing.T) {
|
||||||
|
state := &GitState{
|
||||||
|
SHA: "1234567890abcdef1234567890abcdef12345678",
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
length int
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{"short 7", 7, "1234567"},
|
||||||
|
{"short 10", 10, "1234567890"},
|
||||||
|
{"full SHA", 40, "1234567890abcdef1234567890abcdef12345678"},
|
||||||
|
{"longer than SHA", 50, "1234567890abcdef1234567890abcdef12345678"},
|
||||||
|
{"negative", -42, "1234567890abcdef1234567890abcdef12345678"},
|
||||||
|
{"zero", 0, "1234567890abcdef1234567890abcdef12345678"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := state.ShortSHA(tt.length)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGitState_ShortSHA_Empty(t *testing.T) {
|
||||||
|
state := &GitState{
|
||||||
|
SHA: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
result := state.ShortSHA(7)
|
||||||
|
assert.Empty(t, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions.
|
||||||
|
|
||||||
|
func initGitRepo(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Initialize git repo.
|
||||||
|
runGitCommand(t, dir, "init")
|
||||||
|
runGitCommand(t, dir, "config", "user.email", "test@example.com")
|
||||||
|
runGitCommand(t, dir, "config", "user.name", "Test User")
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAndCommitFile(t *testing.T, dir, filename, content string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Create file.
|
||||||
|
path := filepath.Join(dir, filename)
|
||||||
|
err := os.WriteFile(path, []byte(content), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Commit file.
|
||||||
|
runGitCommand(t, dir, "add", filename)
|
||||||
|
runGitCommand(t, dir, "commit", "-m", "Add "+filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitCommand(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "git command failed: %s", output)
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package log
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitLoggerFromEnv() {
|
||||||
|
debugValues := []string{"1", "true", "yes"}
|
||||||
|
if slices.Contains(debugValues, strings.ToLower(os.Getenv("DEBUG"))) {
|
||||||
|
logger := slog.New(NewSlogTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||||
|
Level: slog.LevelDebug,
|
||||||
|
}))
|
||||||
|
slog.SetDefault(logger)
|
||||||
|
}
|
||||||
|
slog.Debug("logger initialized")
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -20,6 +20,7 @@ service Docker {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
rpc InspectRemoteImage(InspectRemoteImageRequest) returns (InspectRemoteImageResponse);
|
||||||
|
rpc ListImages(ListImagesRequest) returns (ListImagesResponse);
|
||||||
|
|
||||||
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
rpc CreateVolume(CreateVolumeRequest) returns (CreateVolumeResponse);
|
||||||
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
rpc ListVolumes(ListVolumesRequest) returns (ListVolumesResponse);
|
||||||
@@ -29,6 +30,8 @@ service Docker {
|
|||||||
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
|
||||||
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
|
||||||
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
|
||||||
|
|
||||||
|
rpc ExecContainer(stream ExecContainerRequest) returns (stream ExecContainerResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message CreateContainerRequest {
|
message CreateContainerRequest {
|
||||||
@@ -113,7 +116,7 @@ message InspectImageResponse {
|
|||||||
|
|
||||||
message Image {
|
message Image {
|
||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
// JSON serialised types.ImageInspect.
|
// JSON serialised image.InspectResponse.
|
||||||
bytes image = 2;
|
bytes image = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +137,24 @@ message RemoteImage {
|
|||||||
bytes manifest = 3;
|
bytes manifest = 3;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ListImagesRequest {
|
||||||
|
// JSON serialised image.ListOptions.
|
||||||
|
bytes options = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ListImagesResponse {
|
||||||
|
// Must contain only one repeated messages field to allow broadcasting ListImages requests to multiple machines.
|
||||||
|
repeated MachineImages messages = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
message MachineImages {
|
||||||
|
Metadata metadata = 1;
|
||||||
|
// JSON serialised []image.Summary.
|
||||||
|
bytes images = 2;
|
||||||
|
// True if Docker uses the containerd image store, false if it uses its internal image store.
|
||||||
|
bool containerd_store = 3;
|
||||||
|
}
|
||||||
|
|
||||||
message CreateVolumeRequest {
|
message CreateVolumeRequest {
|
||||||
// JSON serialised volume.CreateOptions.
|
// JSON serialised volume.CreateOptions.
|
||||||
bytes options = 1;
|
bytes options = 1;
|
||||||
@@ -195,3 +216,39 @@ message MachineServiceContainers {
|
|||||||
Metadata metadata = 1;
|
Metadata metadata = 1;
|
||||||
repeated ServiceContainer containers = 2;
|
repeated ServiceContainer containers = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message ExecContainerRequest {
|
||||||
|
oneof payload {
|
||||||
|
// Initial configuration for the exec session. Must be sent as the first message.
|
||||||
|
ExecConfig config = 1;
|
||||||
|
// Raw stdin data to be written to the exec process.
|
||||||
|
bytes stdin = 2;
|
||||||
|
// TTY resize event (only used when TTY is enabled).
|
||||||
|
ResizeEvent resize = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecConfig {
|
||||||
|
// Container ID to execute the command in.
|
||||||
|
string container_id = 1;
|
||||||
|
// JSON serialised ExecOptions
|
||||||
|
bytes options = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ResizeEvent {
|
||||||
|
uint32 height = 1;
|
||||||
|
uint32 width = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message ExecContainerResponse {
|
||||||
|
oneof payload {
|
||||||
|
// Exec instance ID returned after creating the exec.
|
||||||
|
string exec_id = 1;
|
||||||
|
// Raw stdout data from the exec process.
|
||||||
|
bytes stdout = 2;
|
||||||
|
// Raw stderr data from the exec process (only when TTY is disabled).
|
||||||
|
bytes stderr = 3;
|
||||||
|
// Exit code of the exec process. Sent as the final message.
|
||||||
|
int32 exit_code = 4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const (
|
|||||||
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
Docker_PullImage_FullMethodName = "/api.Docker/PullImage"
|
||||||
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
Docker_InspectImage_FullMethodName = "/api.Docker/InspectImage"
|
||||||
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
Docker_InspectRemoteImage_FullMethodName = "/api.Docker/InspectRemoteImage"
|
||||||
|
Docker_ListImages_FullMethodName = "/api.Docker/ListImages"
|
||||||
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
Docker_CreateVolume_FullMethodName = "/api.Docker/CreateVolume"
|
||||||
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
Docker_ListVolumes_FullMethodName = "/api.Docker/ListVolumes"
|
||||||
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
Docker_RemoveVolume_FullMethodName = "/api.Docker/RemoveVolume"
|
||||||
@@ -36,6 +37,7 @@ const (
|
|||||||
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
Docker_InspectServiceContainer_FullMethodName = "/api.Docker/InspectServiceContainer"
|
||||||
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
Docker_ListServiceContainers_FullMethodName = "/api.Docker/ListServiceContainers"
|
||||||
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
Docker_RemoveServiceContainer_FullMethodName = "/api.Docker/RemoveServiceContainer"
|
||||||
|
Docker_ExecContainer_FullMethodName = "/api.Docker/ExecContainer"
|
||||||
)
|
)
|
||||||
|
|
||||||
// DockerClient is the client API for Docker service.
|
// DockerClient is the client API for Docker service.
|
||||||
@@ -53,6 +55,7 @@ type DockerClient interface {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
InspectRemoteImage(ctx context.Context, in *InspectRemoteImageRequest, opts ...grpc.CallOption) (*InspectRemoteImageResponse, error)
|
||||||
|
ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error)
|
||||||
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
ListVolumes(ctx context.Context, in *ListVolumesRequest, opts ...grpc.CallOption) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveVolume(ctx context.Context, in *RemoveVolumeRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
@@ -60,6 +63,7 @@ type DockerClient interface {
|
|||||||
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
InspectServiceContainer(ctx context.Context, in *InspectContainerRequest, opts ...grpc.CallOption) (*ServiceContainer, error)
|
||||||
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
ListServiceContainers(ctx context.Context, in *ListServiceContainersRequest, opts ...grpc.CallOption) (*ListServiceContainersResponse, error)
|
||||||
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
RemoveServiceContainer(ctx context.Context, in *RemoveContainerRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
|
||||||
|
ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type dockerClient struct {
|
type dockerClient struct {
|
||||||
@@ -169,6 +173,16 @@ func (c *dockerClient) InspectRemoteImage(ctx context.Context, in *InspectRemote
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ListImages(ctx context.Context, in *ListImagesRequest, opts ...grpc.CallOption) (*ListImagesResponse, error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
out := new(ListImagesResponse)
|
||||||
|
err := c.cc.Invoke(ctx, Docker_ListImages_FullMethodName, in, out, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
func (c *dockerClient) CreateVolume(ctx context.Context, in *CreateVolumeRequest, opts ...grpc.CallOption) (*CreateVolumeResponse, error) {
|
||||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
out := new(CreateVolumeResponse)
|
out := new(CreateVolumeResponse)
|
||||||
@@ -239,6 +253,19 @@ func (c *dockerClient) RemoveServiceContainer(ctx context.Context, in *RemoveCon
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *dockerClient) ExecContainer(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse], error) {
|
||||||
|
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||||
|
stream, err := c.cc.NewStream(ctx, &Docker_ServiceDesc.Streams[1], Docker_ExecContainer_FullMethodName, cOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
x := &grpc.GenericClientStream[ExecContainerRequest, ExecContainerResponse]{ClientStream: stream}
|
||||||
|
return x, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type Docker_ExecContainerClient = grpc.BidiStreamingClient[ExecContainerRequest, ExecContainerResponse]
|
||||||
|
|
||||||
// DockerServer is the server API for Docker service.
|
// DockerServer is the server API for Docker service.
|
||||||
// All implementations must embed UnimplementedDockerServer
|
// All implementations must embed UnimplementedDockerServer
|
||||||
// for forward compatibility.
|
// for forward compatibility.
|
||||||
@@ -254,6 +281,7 @@ type DockerServer interface {
|
|||||||
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
// InspectRemoteImage returns the image metadata for an image in a remote registry using the machine's
|
||||||
// Docker auth credentials if necessary.
|
// Docker auth credentials if necessary.
|
||||||
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error)
|
||||||
|
ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error)
|
||||||
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error)
|
||||||
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
ListVolumes(context.Context, *ListVolumesRequest) (*ListVolumesResponse, error)
|
||||||
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
RemoveVolume(context.Context, *RemoveVolumeRequest) (*emptypb.Empty, error)
|
||||||
@@ -261,6 +289,7 @@ type DockerServer interface {
|
|||||||
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
InspectServiceContainer(context.Context, *InspectContainerRequest) (*ServiceContainer, error)
|
||||||
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
ListServiceContainers(context.Context, *ListServiceContainersRequest) (*ListServiceContainersResponse, error)
|
||||||
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error)
|
||||||
|
ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error
|
||||||
mustEmbedUnimplementedDockerServer()
|
mustEmbedUnimplementedDockerServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,6 +327,9 @@ func (UnimplementedDockerServer) InspectImage(context.Context, *InspectImageRequ
|
|||||||
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
func (UnimplementedDockerServer) InspectRemoteImage(context.Context, *InspectRemoteImageRequest) (*InspectRemoteImageResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method InspectRemoteImage not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedDockerServer) ListImages(context.Context, *ListImagesRequest) (*ListImagesResponse, error) {
|
||||||
|
return nil, status.Errorf(codes.Unimplemented, "method ListImages not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
func (UnimplementedDockerServer) CreateVolume(context.Context, *CreateVolumeRequest) (*CreateVolumeResponse, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method CreateVolume not implemented")
|
||||||
}
|
}
|
||||||
@@ -319,6 +351,9 @@ func (UnimplementedDockerServer) ListServiceContainers(context.Context, *ListSer
|
|||||||
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
func (UnimplementedDockerServer) RemoveServiceContainer(context.Context, *RemoveContainerRequest) (*emptypb.Empty, error) {
|
||||||
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
return nil, status.Errorf(codes.Unimplemented, "method RemoveServiceContainer not implemented")
|
||||||
}
|
}
|
||||||
|
func (UnimplementedDockerServer) ExecContainer(grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]) error {
|
||||||
|
return status.Errorf(codes.Unimplemented, "method ExecContainer not implemented")
|
||||||
|
}
|
||||||
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
func (UnimplementedDockerServer) mustEmbedUnimplementedDockerServer() {}
|
||||||
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
func (UnimplementedDockerServer) testEmbeddedByValue() {}
|
||||||
|
|
||||||
@@ -495,6 +530,24 @@ func _Docker_InspectRemoteImage_Handler(srv interface{}, ctx context.Context, de
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Docker_ListImages_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
|
in := new(ListImagesRequest)
|
||||||
|
if err := dec(in); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if interceptor == nil {
|
||||||
|
return srv.(DockerServer).ListImages(ctx, in)
|
||||||
|
}
|
||||||
|
info := &grpc.UnaryServerInfo{
|
||||||
|
Server: srv,
|
||||||
|
FullMethod: Docker_ListImages_FullMethodName,
|
||||||
|
}
|
||||||
|
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||||
|
return srv.(DockerServer).ListImages(ctx, req.(*ListImagesRequest))
|
||||||
|
}
|
||||||
|
return interceptor(ctx, in, info, handler)
|
||||||
|
}
|
||||||
|
|
||||||
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
func _Docker_CreateVolume_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||||
in := new(CreateVolumeRequest)
|
in := new(CreateVolumeRequest)
|
||||||
if err := dec(in); err != nil {
|
if err := dec(in); err != nil {
|
||||||
@@ -621,6 +674,13 @@ func _Docker_RemoveServiceContainer_Handler(srv interface{}, ctx context.Context
|
|||||||
return interceptor(ctx, in, info, handler)
|
return interceptor(ctx, in, info, handler)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func _Docker_ExecContainer_Handler(srv interface{}, stream grpc.ServerStream) error {
|
||||||
|
return srv.(DockerServer).ExecContainer(&grpc.GenericServerStream[ExecContainerRequest, ExecContainerResponse]{ServerStream: stream})
|
||||||
|
}
|
||||||
|
|
||||||
|
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
|
||||||
|
type Docker_ExecContainerServer = grpc.BidiStreamingServer[ExecContainerRequest, ExecContainerResponse]
|
||||||
|
|
||||||
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
// Docker_ServiceDesc is the grpc.ServiceDesc for Docker service.
|
||||||
// It's only intended for direct use with grpc.RegisterService,
|
// It's only intended for direct use with grpc.RegisterService,
|
||||||
// and not to be introspected or modified (even as a copy)
|
// and not to be introspected or modified (even as a copy)
|
||||||
@@ -660,6 +720,10 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
MethodName: "InspectRemoteImage",
|
MethodName: "InspectRemoteImage",
|
||||||
Handler: _Docker_InspectRemoteImage_Handler,
|
Handler: _Docker_InspectRemoteImage_Handler,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
MethodName: "ListImages",
|
||||||
|
Handler: _Docker_ListImages_Handler,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
MethodName: "CreateVolume",
|
MethodName: "CreateVolume",
|
||||||
Handler: _Docker_CreateVolume_Handler,
|
Handler: _Docker_CreateVolume_Handler,
|
||||||
@@ -695,6 +759,12 @@ var Docker_ServiceDesc = grpc.ServiceDesc{
|
|||||||
Handler: _Docker_PullImage_Handler,
|
Handler: _Docker_PullImage_Handler,
|
||||||
ServerStreams: true,
|
ServerStreams: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
StreamName: "ExecContainer",
|
||||||
|
Handler: _Docker_ExecContainer_Handler,
|
||||||
|
ServerStreams: true,
|
||||||
|
ClientStreams: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Metadata: "internal/machine/api/pb/docker.proto",
|
Metadata: "internal/machine/api/pb/docker.proto",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
@@ -848,15 +847,15 @@ func newContainerRecordWithCaddyConfig(serviceName, ip, caddyConfig, machineID s
|
|||||||
return store.ContainerRecord{
|
return store.ContainerRecord{
|
||||||
Container: api.ServiceContainer{
|
Container: api.ServiceContainer{
|
||||||
Container: api.Container{
|
Container: api.Container{
|
||||||
ContainerJSON: types.ContainerJSON{
|
InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
Created: created.UTC().Format(time.RFC3339Nano),
|
Created: created.UTC().Format(time.RFC3339Nano),
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
@@ -975,15 +974,15 @@ func newContainerRecordWithPorts(serviceName, ip string, ports []string, machine
|
|||||||
return store.ContainerRecord{
|
return store.ContainerRecord{
|
||||||
Container: api.ServiceContainer{
|
Container: api.ServiceContainer{
|
||||||
Container: api.Container{
|
Container: api.Container{
|
||||||
ContainerJSON: types.ContainerJSON{
|
InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
ID: serviceName + "-" + ip, // Add ID for stable sorting
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
Created: time.Now().UTC().Format(time.RFC3339Nano),
|
Created: time.Now().UTC().Format(time.RFC3339Nano),
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
@@ -334,13 +333,13 @@ func TestGenerateJSONConfig(t *testing.T) {
|
|||||||
|
|
||||||
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
func newContainer(ip string, ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
docker.NetworkName: {
|
docker.NetworkName: {
|
||||||
IPAddress: ip,
|
IPAddress: ip,
|
||||||
@@ -357,13 +356,13 @@ func newContainer(ip string, ports ...string) api.ServiceContainer {
|
|||||||
|
|
||||||
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
func newContainerWithoutNetwork(ports ...string) api.ServiceContainer {
|
||||||
portsLabel := strings.Join(ports, ",")
|
portsLabel := strings.Join(ports, ",")
|
||||||
return api.ServiceContainer{Container: api.Container{ContainerJSON: types.ContainerJSON{
|
return api.ServiceContainer{Container: api.Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
NetworkSettings: &types.NetworkSettings{
|
NetworkSettings: &container.NetworkSettings{
|
||||||
Networks: map[string]*network.EndpointSettings{
|
Networks: map[string]*network.EndpointSettings{
|
||||||
"other-network": {
|
"other-network": {
|
||||||
IPAddress: "172.17.0.2",
|
IPAddress: "172.17.0.2",
|
||||||
|
|||||||
+29
-17
@@ -92,7 +92,7 @@ func newClusterController(
|
|||||||
func (cc *clusterController) Run(ctx context.Context) error {
|
func (cc *clusterController) Run(ctx context.Context) error {
|
||||||
defer close(cc.stopped)
|
defer close(cc.stopped)
|
||||||
|
|
||||||
if err := firewall.ConfigureIptablesChains(); err != nil {
|
if err := firewall.ConfigureIptablesChains(network.MachineIP(cc.state.Network.Subnet)); err != nil {
|
||||||
return fmt.Errorf("configure iptables chains: %w", err)
|
return fmt.Errorf("configure iptables chains: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,13 +215,15 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
errGroup.Go(func() error {
|
if cc.unregistry != nil {
|
||||||
slog.Info("Starting unregistry server.")
|
errGroup.Go(func() error {
|
||||||
if err := cc.unregistry.ListenAndServe(); err != nil {
|
slog.Info("Starting unregistry server.")
|
||||||
return fmt.Errorf("unregistry server failed: %w", err)
|
if err := cc.unregistry.ListenAndServe(); err != nil {
|
||||||
}
|
return fmt.Errorf("unregistry server failed: %w", err)
|
||||||
return nil
|
}
|
||||||
})
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for the context to be done and stop the network API server.
|
// Wait for the context to be done and stop the network API server.
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
@@ -230,23 +232,25 @@ func (cc *clusterController) Run(ctx context.Context) error {
|
|||||||
cc.server.GracefulStop()
|
cc.server.GracefulStop()
|
||||||
slog.Info("Network API server stopped.")
|
slog.Info("Network API server stopped.")
|
||||||
|
|
||||||
// Stop the unregistry server with a timeout.
|
// Stop the unregistry server with a timeout if it was started.
|
||||||
unregTimeout := 30 * time.Second
|
if cc.unregistry != nil {
|
||||||
slog.Info("Stopping unregistry server.", "timeout", unregTimeout)
|
unregTimeout := 30 * time.Second
|
||||||
unregCtx, cancel := context.WithTimeout(context.Background(), unregTimeout)
|
slog.Info("Stopping unregistry server.", "timeout", unregTimeout)
|
||||||
defer cancel()
|
unregCtx, cancel := context.WithTimeout(context.Background(), unregTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
if err = cc.unregistry.Shutdown(unregCtx); err != nil {
|
if err = cc.unregistry.Shutdown(unregCtx); err != nil {
|
||||||
return fmt.Errorf("unregistry server forced to shutdown: %w", err)
|
return fmt.Errorf("unregistry server forced to shutdown: %w", err)
|
||||||
|
}
|
||||||
|
slog.Info("Unregistry server stopped.")
|
||||||
}
|
}
|
||||||
slog.Info("Unregistry server stopped.")
|
|
||||||
|
|
||||||
// Wait for all controllers to finish.
|
// Wait for all controllers to finish.
|
||||||
err = errGroup.Wait()
|
err = errGroup.Wait()
|
||||||
|
|
||||||
// It's safe to stop the Corrosion service after the controllers depending on it and API server are stopped.
|
// It's safe to stop the Corrosion service after the controllers depending on it and API server are stopped.
|
||||||
// Use a new context with a timeout as the current context is already canceled.
|
// Use a new context with a timeout as the current context is already canceled.
|
||||||
ctx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if corroErr := cc.corroService.Stop(ctx); corroErr != nil {
|
if corroErr := cc.corroService.Stop(ctx); corroErr != nil {
|
||||||
err = errors.Join(err, fmt.Errorf("stop corrosion service: %w", corroErr))
|
err = errors.Join(err, fmt.Errorf("stop corrosion service: %w", corroErr))
|
||||||
@@ -354,6 +358,14 @@ func (cc *clusterController) handleMachineChanges(ctx context.Context) error {
|
|||||||
slog.Error("Failed to list machines.", "err", err)
|
slog.Error("Failed to list machines.", "err", err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// Skip reconfiguration if the machines list is empty. This can happen when joining the cluster.
|
||||||
|
// Corrosion can notifies about table changes before the data is fully replicated.
|
||||||
|
// Reconfiguring with an empty list would remove all peers and lock this machine out of the cluster.
|
||||||
|
// See https://github.com/psviderski/uncloud/issues/155.
|
||||||
|
if len(machines) == 0 {
|
||||||
|
slog.Debug("Skipping peer reconfiguration: machines list in store is empty.")
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err = cc.configurePeers(machines); err != nil {
|
if err = cc.configurePeers(machines); err != nil {
|
||||||
slog.Error("Failed to configure peers.", "err", err)
|
slog.Error("Failed to configure peers.", "err", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
@@ -30,7 +31,7 @@ type DockerService struct {
|
|||||||
func (s *DockerService) Start(ctx context.Context) error {
|
func (s *DockerService) Start(ctx context.Context) error {
|
||||||
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
_, err := s.Client.ContainerInspect(ctx, s.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
return fmt.Errorf("inspect container %q: %w", s.Name, err)
|
||||||
}
|
}
|
||||||
if err = s.startNewContainer(ctx); err != nil {
|
if err = s.startNewContainer(ctx); err != nil {
|
||||||
@@ -114,7 +115,7 @@ func (s *DockerService) hostConfig() *container.HostConfig {
|
|||||||
func (s *DockerService) startNewContainer(ctx context.Context) error {
|
func (s *DockerService) startNewContainer(ctx context.Context) error {
|
||||||
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
_, err := s.Client.ContainerCreate(ctx, s.containerConfig(), s.hostConfig(), nil, nil, s.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("create container: %w", err)
|
return fmt.Errorf("create container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
newServiceIPs[ctr.ServiceName()] = append(newServiceIPs[ctr.ServiceName()], ip)
|
||||||
// Also add the service ID as a valid lookup.
|
// Also add the service ID as a valid lookup.
|
||||||
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
newServiceIPs[ctr.ServiceID()] = append(newServiceIPs[ctr.ServiceID()], ip)
|
||||||
|
|
||||||
|
// Add <machine-id>.m.<service-name> as a lookup
|
||||||
|
serviceNameWithMachineID := record.MachineID + ".m." + ctr.ServiceName()
|
||||||
|
newServiceIPs[serviceNameWithMachineID] = append(newServiceIPs[serviceNameWithMachineID], ip)
|
||||||
|
|
||||||
containersCount++
|
containersCount++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -99,7 +104,7 @@ func (r *ClusterResolver) updateServiceIPs(containers []store.ContainerRecord) {
|
|||||||
r.serviceIPs = newServiceIPs
|
r.serviceIPs = newServiceIPs
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/2, "containers", containersCount)
|
r.log.Debug("DNS records updated.", "services", len(newServiceIPs)/3, "containers", containersCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve returns IP addresses of the service containers.
|
// Resolve returns IP addresses of the service containers.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"math/rand/v2"
|
"math/rand/v2"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
@@ -40,6 +41,7 @@ type Resolver interface {
|
|||||||
// to upstream DNS servers.
|
// to upstream DNS servers.
|
||||||
type Server struct {
|
type Server struct {
|
||||||
listenAddr netip.Addr
|
listenAddr netip.Addr
|
||||||
|
localSubnet netip.Prefix
|
||||||
resolver Resolver
|
resolver Resolver
|
||||||
upstreamServers []netip.AddrPort
|
upstreamServers []netip.AddrPort
|
||||||
|
|
||||||
@@ -53,7 +55,7 @@ type Server struct {
|
|||||||
// NewServer creates a new DNS server with the given configuration.
|
// NewServer creates a new DNS server with the given configuration.
|
||||||
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
|
// If upstreams is nil, nameservers from /etc/resolv.conf will be used. An empty upstreams list means to only resolve
|
||||||
// internal DNS queries and not forward any external queries.
|
// internal DNS queries and not forward any external queries.
|
||||||
func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
|
func NewServer(listenAddr netip.Addr, localSubnet netip.Prefix, resolver Resolver, upstreams []netip.AddrPort) (*Server, error) {
|
||||||
if !listenAddr.IsValid() {
|
if !listenAddr.IsValid() {
|
||||||
return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
|
return nil, fmt.Errorf("invalid listen address: %s", listenAddr)
|
||||||
}
|
}
|
||||||
@@ -87,6 +89,7 @@ func NewServer(listenAddr netip.Addr, resolver Resolver, upstreams []netip.AddrP
|
|||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
listenAddr: listenAddr,
|
listenAddr: listenAddr,
|
||||||
|
localSubnet: localSubnet,
|
||||||
resolver: resolver,
|
resolver: resolver,
|
||||||
upstreamServers: upstreams,
|
upstreamServers: upstreams,
|
||||||
forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
|
forwardSemaphore: make(chan struct{}, maxConcurrentForwards),
|
||||||
@@ -287,7 +290,7 @@ func (s *Server) forwardRequest(req *dns.Msg, proto string) (*dns.Msg, error) {
|
|||||||
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
// handleAQuery processes an A query for the internal domain and returns A records for the requested name.
|
||||||
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
// The internal domain suffix is already stripped from the name. An empty list is returned if no records are found.
|
||||||
func (s *Server) handleAQuery(name string) []dns.RR {
|
func (s *Server) handleAQuery(name string) []dns.RR {
|
||||||
serviceName := trimInternalDomain(name)
|
serviceName, mode := extractModeFromDomain(trimInternalDomain(name))
|
||||||
ips := s.resolver.Resolve(serviceName)
|
ips := s.resolver.Resolve(serviceName)
|
||||||
if len(ips) == 0 {
|
if len(ips) == 0 {
|
||||||
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
s.log.Debug("Failed to resolve service name.", "service", serviceName)
|
||||||
@@ -296,10 +299,28 @@ func (s *Server) handleAQuery(name string) []dns.RR {
|
|||||||
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
s.log.Debug("Resolved service name.", "service", serviceName, "ips", ips)
|
||||||
|
|
||||||
if len(ips) > 1 {
|
if len(ips) > 1 {
|
||||||
// TODO: sort by proximity to the requesting container/machine. For now, just shuffle the IPs.
|
// Shuffle the IPs to approximate round-robin.
|
||||||
|
// We want to do this as a baseline for "nearest" mode, as well.
|
||||||
rand.Shuffle(len(ips), func(i, j int) {
|
rand.Shuffle(len(ips), func(i, j int) {
|
||||||
ips[i], ips[j] = ips[j], ips[i]
|
ips[i], ips[j] = ips[j], ips[i]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Default (mode == "") currently behaves the same as round-robin,
|
||||||
|
// and nothing additional to do for round-robin (mode == "rr").
|
||||||
|
|
||||||
|
if mode == "nearest" {
|
||||||
|
// Sort IPs on local subnet to the top.
|
||||||
|
slices.SortFunc(ips, func(a, b netip.Addr) int {
|
||||||
|
aIsLocal := s.localSubnet.Contains(a)
|
||||||
|
bIsLocal := s.localSubnet.Contains(b)
|
||||||
|
if aIsLocal && !bIsLocal {
|
||||||
|
return -1
|
||||||
|
} else if bIsLocal && !aIsLocal {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create A records for each IP.
|
// Create A records for each IP.
|
||||||
@@ -345,3 +366,13 @@ func trimInternalDomain(name string) string {
|
|||||||
|
|
||||||
return strings.TrimSuffix(name, "."+InternalDomain)
|
return strings.TrimSuffix(name, "."+InternalDomain)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func extractModeFromDomain(name string) (string, string) {
|
||||||
|
modes := []string{"nearest", "rr"}
|
||||||
|
for _, mode := range modes {
|
||||||
|
if cut, found := strings.CutPrefix(name, mode+"."); found {
|
||||||
|
return cut, mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return name, ""
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
|
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
@@ -16,6 +15,7 @@ import (
|
|||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
regtypes "github.com/google/go-containerregistry/pkg/v1/types"
|
||||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
@@ -24,16 +24,18 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
// Client is a gRPC client for the Docker service that provides a similar interface to the Docker HTTP client.
|
||||||
|
// TODO: it doesn't seem there is much value in having this intermediate Docker client.
|
||||||
|
// Consider merging it into the main pkg/client.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn *grpc.ClientConn
|
conn *grpc.ClientConn
|
||||||
grpcClient pb.DockerClient
|
GRPCClient pb.DockerClient
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
// NewClient creates a new Docker gRPC client with the provided gRPC connection.
|
||||||
func NewClient(conn *grpc.ClientConn) *Client {
|
func NewClient(conn *grpc.ClientConn) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
grpcClient: pb.NewDockerClient(conn),
|
GRPCClient: pb.NewDockerClient(conn),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,7 +72,7 @@ func (c *Client) CreateContainer(
|
|||||||
return resp, fmt.Errorf("marshal platform: %w", err)
|
return resp, fmt.Errorf("marshal platform: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
grpcResp, err := c.grpcClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
grpcResp, err := c.GRPCClient.CreateContainer(ctx, &pb.CreateContainerRequest{
|
||||||
Config: configBytes,
|
Config: configBytes,
|
||||||
HostConfig: hostConfigBytes,
|
HostConfig: hostConfigBytes,
|
||||||
NetworkConfig: networkingConfigBytes,
|
NetworkConfig: networkingConfigBytes,
|
||||||
@@ -91,10 +93,10 @@ func (c *Client) CreateContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InspectContainer returns the container information for the given container ID.
|
// InspectContainer returns the container information for the given container ID.
|
||||||
func (c *Client) InspectContainer(ctx context.Context, id string) (types.ContainerJSON, error) {
|
func (c *Client) InspectContainer(ctx context.Context, id string) (container.InspectResponse, error) {
|
||||||
var resp types.ContainerJSON
|
var resp container.InspectResponse
|
||||||
|
|
||||||
grpcResp, err := c.grpcClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
grpcResp, err := c.GRPCClient.InspectContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
return resp, errdefs.NotFound(err)
|
return resp, errdefs.NotFound(err)
|
||||||
@@ -115,7 +117,7 @@ func (c *Client) StartContainer(ctx context.Context, id string, opts container.S
|
|||||||
return fmt.Errorf("marshal options: %w", err)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.StartContainer(ctx, &pb.StartContainerRequest{
|
_, err = c.GRPCClient.StartContainer(ctx, &pb.StartContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -134,7 +136,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
|||||||
return fmt.Errorf("marshal options: %w", err)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.StopContainer(ctx, &pb.StopContainerRequest{
|
_, err = c.GRPCClient.StopContainer(ctx, &pb.StopContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -148,7 +150,7 @@ func (c *Client) StopContainer(ctx context.Context, id string, opts container.St
|
|||||||
|
|
||||||
type MachineContainers struct {
|
type MachineContainers struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Containers []types.ContainerJSON
|
Containers []container.InspectResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
|
func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions) ([]MachineContainers, error) {
|
||||||
@@ -157,7 +159,7 @@ func (c *Client) ListContainers(ctx context.Context, opts container.ListOptions)
|
|||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.grpcClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
resp, err := c.GRPCClient.ListContainers(ctx, &pb.ListContainersRequest{Options: optsBytes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -184,7 +186,7 @@ func (c *Client) RemoveContainer(ctx context.Context, id string, opts container.
|
|||||||
return fmt.Errorf("marshal options: %w", err)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
_, err = c.GRPCClient.RemoveContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -205,23 +207,18 @@ type PullOptions struct {
|
|||||||
Platform string
|
Platform string
|
||||||
}
|
}
|
||||||
|
|
||||||
type PullImageMessage struct {
|
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan docker.PullPushImageMessage, error) {
|
||||||
Message jsonmessage.JSONMessage
|
|
||||||
Err error
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions) (<-chan PullImageMessage, error) {
|
|
||||||
optsBytes, err := json.Marshal(opts)
|
optsBytes, err := json.Marshal(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
stream, err := c.grpcClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
|
stream, err := c.GRPCClient.PullImage(ctx, &pb.PullImageRequest{Image: image, Options: optsBytes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
ch := make(chan PullImageMessage)
|
ch := make(chan docker.PullPushImageMessage)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(ch)
|
defer close(ch)
|
||||||
@@ -232,16 +229,16 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ch <- PullImageMessage{Err: err}
|
ch <- docker.PullPushImageMessage{Err: err}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var jm jsonmessage.JSONMessage
|
var jm jsonmessage.JSONMessage
|
||||||
if err = json.Unmarshal(msg.Message, &jm); err != nil {
|
if err = json.Unmarshal(msg.Message, &jm); err != nil {
|
||||||
ch <- PullImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
ch <- docker.PullPushImageMessage{Err: fmt.Errorf("unmarshal JSON message: %w", err)}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ch <- PullImageMessage{Message: jm}
|
ch <- docker.PullPushImageMessage{Message: jm}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -250,7 +247,7 @@ func (c *Client) PullImage(ctx context.Context, image string, opts PullOptions)
|
|||||||
|
|
||||||
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
// InspectImage returns the image information for the given image ID. The request may be sent to multiple machines.
|
||||||
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
resp, err := c.grpcClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
resp, err := c.GRPCClient.InspectImage(ctx, &pb.InspectImageRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the request was sent to only one machine, err is an actual error from the machine.
|
// If the request was sent to only one machine, err is an actual error from the machine.
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
@@ -288,7 +285,7 @@ func (c *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImag
|
|||||||
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
// credentials if necessary. If the response from a machine doesn't contain an error, the api.RemoteImage will either
|
||||||
// contain an IndexManifest or an ImageManifest.
|
// contain an IndexManifest or an ImageManifest.
|
||||||
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
func (c *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
resp, err := c.grpcClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
resp, err := c.GRPCClient.InspectRemoteImage(ctx, &pb.InspectRemoteImageRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -365,7 +362,7 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
|||||||
return vol, fmt.Errorf("marshal options: %w", err)
|
return vol, fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.grpcClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
resp, err := c.GRPCClient.CreateVolume(ctx, &pb.CreateVolumeRequest{Options: optsBytes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return vol, err
|
return vol, err
|
||||||
}
|
}
|
||||||
@@ -377,20 +374,18 @@ func (c *Client) CreateVolume(ctx context.Context, opts volume.CreateOptions) (v
|
|||||||
return vol, nil
|
return vol, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineVolumes represents a volume list response from a machine.
|
|
||||||
type MachineVolumes struct {
|
type MachineVolumes struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Response volume.ListResponse
|
Response volume.ListResponse
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListVolumes returns a list of all volumes matching the filter.
|
|
||||||
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]MachineVolumes, error) {
|
||||||
optsBytes, err := json.Marshal(opts)
|
optsBytes, err := json.Marshal(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.grpcClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
resp, err := c.GRPCClient.ListVolumes(ctx, &pb.ListVolumesRequest{Options: optsBytes})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -412,7 +407,7 @@ func (c *Client) ListVolumes(ctx context.Context, opts volume.ListOptions) ([]Ma
|
|||||||
|
|
||||||
// RemoveVolume removes a volume with the given ID.
|
// RemoveVolume removes a volume with the given ID.
|
||||||
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
func (c *Client) RemoveVolume(ctx context.Context, id string, force bool) error {
|
||||||
_, err := c.grpcClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
_, err := c.GRPCClient.RemoveVolume(ctx, &pb.RemoveVolumeRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Force: force,
|
Force: force,
|
||||||
})
|
})
|
||||||
@@ -435,7 +430,7 @@ func (c *Client) CreateServiceContainer(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return resp, fmt.Errorf("marshal service spec: %w", err)
|
return resp, fmt.Errorf("marshal service spec: %w", err)
|
||||||
}
|
}
|
||||||
grpcResp, err := c.grpcClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
grpcResp, err := c.GRPCClient.CreateServiceContainer(ctx, &pb.CreateServiceContainerRequest{
|
||||||
ServiceId: serviceID,
|
ServiceId: serviceID,
|
||||||
ServiceSpec: specBytes,
|
ServiceSpec: specBytes,
|
||||||
ContainerName: containerName,
|
ContainerName: containerName,
|
||||||
@@ -458,7 +453,7 @@ func (c *Client) CreateServiceContainer(
|
|||||||
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
func (c *Client) InspectServiceContainer(ctx context.Context, id string) (api.ServiceContainer, error) {
|
||||||
var resp api.ServiceContainer
|
var resp api.ServiceContainer
|
||||||
|
|
||||||
grpcResp, err := c.grpcClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
grpcResp, err := c.GRPCClient.InspectServiceContainer(ctx, &pb.InspectContainerRequest{Id: id})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if status.Convert(err).Code() == codes.NotFound {
|
if status.Convert(err).Code() == codes.NotFound {
|
||||||
return resp, errdefs.NotFound(err)
|
return resp, errdefs.NotFound(err)
|
||||||
@@ -491,7 +486,7 @@ func (c *Client) ListServiceContainers(
|
|||||||
return nil, fmt.Errorf("marshal options: %w", err)
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.grpcClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
resp, err := c.GRPCClient.ListServiceContainers(ctx, &pb.ListServiceContainersRequest{
|
||||||
ServiceId: serviceNameOrID,
|
ServiceId: serviceNameOrID,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
@@ -530,7 +525,7 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
|
|||||||
return fmt.Errorf("marshal options: %w", err)
|
return fmt.Errorf("marshal options: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = c.grpcClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
_, err = c.GRPCClient.RemoveServiceContainer(ctx, &pb.RemoveContainerRequest{
|
||||||
Id: id,
|
Id: id,
|
||||||
Options: optsBytes,
|
Options: optsBytes,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,303 @@
|
|||||||
|
package docker
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
|
||||||
|
"github.com/moby/term"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"golang.org/x/sync/errgroup"
|
||||||
|
"golang.org/x/sys/unix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ExecConfig contains options for executing a command in a container.
|
||||||
|
type ExecConfig struct {
|
||||||
|
// Container ID to execute the command in.
|
||||||
|
ContainerID string
|
||||||
|
// Exec configuration.
|
||||||
|
Options api.ExecOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendResizeRequest sends a terminal resize request to the exec stream.
|
||||||
|
func sendResizeRequest(stream pb.Docker_ExecContainerClient, size *term.Winsize) error {
|
||||||
|
slog.Debug("sending resize request", "width", size.Width, "height", size.Height)
|
||||||
|
return stream.Send(
|
||||||
|
&pb.ExecContainerRequest{
|
||||||
|
Payload: &pb.ExecContainerRequest_Resize{
|
||||||
|
Resize: &pb.ResizeEvent{
|
||||||
|
Height: uint32(size.Height),
|
||||||
|
Width: uint32(size.Width),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupTerminal configures the terminal for interactive TTY sessions.
|
||||||
|
// It checks if stdin is a terminal, sets it to raw mode, and sets up resize handling.
|
||||||
|
// Returns a cleanup function to restore terminal state, or an error.
|
||||||
|
func setupTerminal(ctx context.Context, stream pb.Docker_ExecContainerClient) (func(), error) {
|
||||||
|
inFd, isTerminal := term.GetFdInfo(os.Stdin)
|
||||||
|
if !isTerminal {
|
||||||
|
return nil, fmt.Errorf("stdin is not a terminal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set terminal to raw mode
|
||||||
|
oldState, err := term.SetRawTerminal(inFd)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("set raw terminal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup function
|
||||||
|
restoreFunc := func() {
|
||||||
|
_ = term.RestoreTerminal(inFd, oldState)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up resize handling
|
||||||
|
if err := handleTerminalResize(ctx, inFd, stream); err != nil {
|
||||||
|
restoreFunc()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return restoreFunc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTerminalResize sends initial window size and handles window resize signals for TTY sessions.
|
||||||
|
func handleTerminalResize(ctx context.Context, inFd uintptr, stream pb.Docker_ExecContainerClient) error {
|
||||||
|
// Handle window resize signals
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, unix.SIGWINCH)
|
||||||
|
|
||||||
|
// Send initial window size
|
||||||
|
if size, err := term.GetWinsize(inFd); err == nil {
|
||||||
|
_ = sendResizeRequest(stream, size)
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
defer signal.Stop(sigCh)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-sigCh:
|
||||||
|
size, err := term.GetWinsize(inFd)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("get window size", "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err = sendResizeRequest(stream, size); err != nil {
|
||||||
|
slog.Debug("send resize request", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClientInputStream reads from stdin and sends data to the remote server.
|
||||||
|
// It also periodically checks for context cancellation to exit gracefully when e.g.
|
||||||
|
// the output stream is closed.
|
||||||
|
func handleClientInputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdin io.Reader) error {
|
||||||
|
slog.Debug("Input goroutine started")
|
||||||
|
defer slog.Debug("Input goroutine exited")
|
||||||
|
|
||||||
|
defer stream.CloseSend()
|
||||||
|
|
||||||
|
// Channel to receive stdin data
|
||||||
|
stdinCh := make(chan []byte)
|
||||||
|
|
||||||
|
stdinErrCh := make(chan error, 1)
|
||||||
|
|
||||||
|
// Read from stdin in a separate goroutine
|
||||||
|
// Note: this goroutine may continue blocking on Read even after we exit from the function,
|
||||||
|
// but that's OK - it will eventually unblock when data arrives or stdin closes.
|
||||||
|
go func() {
|
||||||
|
buf := make([]byte, 32*1024) // 32KB buffer
|
||||||
|
for {
|
||||||
|
n, err := stdin.Read(buf)
|
||||||
|
if n > 0 {
|
||||||
|
data := make([]byte, n)
|
||||||
|
copy(data, buf[:n])
|
||||||
|
select {
|
||||||
|
case stdinCh <- data:
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Debug("stdin reader exiting due to context done")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
slog.Debug("stdin reader: EOF received")
|
||||||
|
} else {
|
||||||
|
slog.Debug("stdin reader error", "error", err)
|
||||||
|
}
|
||||||
|
stdinErrCh <- err
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Send stdin data to the server or exit when context is cancelled
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
case data := <-stdinCh:
|
||||||
|
if err := stream.Send(&pb.ExecContainerRequest{
|
||||||
|
Payload: &pb.ExecContainerRequest_Stdin{Stdin: data},
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("send stdin: %w", err)
|
||||||
|
}
|
||||||
|
case err := <-stdinErrCh:
|
||||||
|
if err != io.EOF {
|
||||||
|
return fmt.Errorf("read stdin: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleClientOutputStream receives output from the exec stream and writes to stdout/stderr.
|
||||||
|
// It also captures the exit code and signals completion via context cancellation.
|
||||||
|
func handleClientOutputStream(ctx context.Context, stream pb.Docker_ExecContainerClient, stdout, stderr io.Writer, exitCode *int) error {
|
||||||
|
slog.Debug("Output goroutine started")
|
||||||
|
defer slog.Debug("Output goroutine exited")
|
||||||
|
|
||||||
|
for {
|
||||||
|
resp, err := stream.Recv()
|
||||||
|
if err == io.EOF {
|
||||||
|
slog.Debug("output stream: EOF received")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("receive from stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch payload := resp.Payload.(type) {
|
||||||
|
case *pb.ExecContainerResponse_ExecId:
|
||||||
|
// This is sent first; we already processed it earlier, so just ignore duplicates.
|
||||||
|
case *pb.ExecContainerResponse_Stdout:
|
||||||
|
if _, err := stdout.Write(payload.Stdout); err != nil {
|
||||||
|
return fmt.Errorf("write stdout: %w", err)
|
||||||
|
}
|
||||||
|
case *pb.ExecContainerResponse_Stderr:
|
||||||
|
if _, err := stderr.Write(payload.Stderr); err != nil {
|
||||||
|
return fmt.Errorf("write stderr: %w", err)
|
||||||
|
}
|
||||||
|
case *pb.ExecContainerResponse_ExitCode:
|
||||||
|
slog.Debug("received exit code", "code", payload.ExitCode)
|
||||||
|
*exitCode = int(payload.ExitCode)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecContainer executes a command in a running container with bidirectional streaming.
|
||||||
|
// TODO: This can be merged with pkg/client as it's an unnecessary logic split.
|
||||||
|
func (c *Client) ExecContainer(ctx context.Context, opts ExecConfig) (exitCode int, err error) {
|
||||||
|
// TODO: We need to handle Ctrl-C and other signals here to forward them to the container process.
|
||||||
|
// Right now, Ctrl-C will just terminate the client process, which is not ideal.
|
||||||
|
// We should catch the signal, send it to the container process, and only exit
|
||||||
|
// when the container process exits.
|
||||||
|
|
||||||
|
slog.Debug("starting ExecContainer", "containerID", opts.ContainerID, "options", opts.Options)
|
||||||
|
|
||||||
|
// Initialize exit code to non-zero in case we have to return early
|
||||||
|
exitCode = 1
|
||||||
|
|
||||||
|
// Set up I/O streams - use custom streams if provided, otherwise default to os.Stdin/Stdout/Stderr
|
||||||
|
stdin := io.Reader(os.Stdin)
|
||||||
|
stdout := io.Writer(os.Stdout)
|
||||||
|
stderr := io.Writer(os.Stderr)
|
||||||
|
|
||||||
|
if opts.Options.Stdin != nil {
|
||||||
|
stdin = opts.Options.Stdin
|
||||||
|
}
|
||||||
|
if opts.Options.Stdout != nil {
|
||||||
|
stdout = opts.Options.Stdout
|
||||||
|
}
|
||||||
|
if opts.Options.Stderr != nil {
|
||||||
|
stderr = opts.Options.Stderr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal the exec config
|
||||||
|
configBytes, err := json.Marshal(opts.Options)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("marshal exec config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the bidirectional stream
|
||||||
|
stream, err := c.GRPCClient.ExecContainer(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("create exec stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the initial configuration
|
||||||
|
if err := stream.Send(&pb.ExecContainerRequest{
|
||||||
|
Payload: &pb.ExecContainerRequest_Config{
|
||||||
|
Config: &pb.ExecConfig{
|
||||||
|
ContainerId: opts.ContainerID,
|
||||||
|
Options: configBytes,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}); err != nil {
|
||||||
|
return -1, fmt.Errorf("send exec config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Receive the exec ID
|
||||||
|
resp, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("receive exec ID: %w", err)
|
||||||
|
}
|
||||||
|
execID := resp.GetExecId()
|
||||||
|
if execID == "" {
|
||||||
|
return -1, fmt.Errorf("expected exec ID in first response")
|
||||||
|
}
|
||||||
|
|
||||||
|
errGroup, ctx := errgroup.WithContext(ctx)
|
||||||
|
|
||||||
|
// Create cancellable context for goroutine coordination
|
||||||
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Handle terminal setup for interactive sessions
|
||||||
|
if opts.Options.AttachStdin && opts.Options.Tty {
|
||||||
|
restoreTerminal, err := setupTerminal(ctx, stream)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("setup terminal: %w", err)
|
||||||
|
}
|
||||||
|
if restoreTerminal != nil {
|
||||||
|
defer restoreTerminal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle stdin stream if needed
|
||||||
|
if opts.Options.AttachStdin {
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
return handleClientInputStream(ctx, stream, stdin)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Close send direction immediately if not attaching stdin
|
||||||
|
stream.CloseSend()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle output streams (stdout/stderr)
|
||||||
|
errGroup.Go(func() error {
|
||||||
|
defer cancel()
|
||||||
|
return handleClientOutputStream(ctx, stream, stdout, stderr, &exitCode)
|
||||||
|
})
|
||||||
|
|
||||||
|
err = errGroup.Wait()
|
||||||
|
|
||||||
|
if err == nil && opts.Options.Detach {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitCode, err
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
dockercontainer "github.com/docker/docker/api/types/container"
|
dockercontainer "github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
dnetwork "github.com/docker/docker/api/types/network"
|
dnetwork "github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/client"
|
|
||||||
"github.com/docker/docker/libnetwork/iptables"
|
"github.com/docker/docker/libnetwork/iptables"
|
||||||
"github.com/psviderski/uncloud/internal/machine/dns"
|
"github.com/psviderski/uncloud/internal/machine/dns"
|
||||||
"github.com/psviderski/uncloud/internal/machine/firewall"
|
"github.com/psviderski/uncloud/internal/machine/firewall"
|
||||||
@@ -27,7 +27,7 @@ func (c *Controller) EnsureUncloudNetwork(ctx context.Context, subnet netip.Pref
|
|||||||
needsCreation := false
|
needsCreation := false
|
||||||
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
nw, err := c.client.NetworkInspect(ctx, NetworkName, dnetwork.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if !client.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
return fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err)
|
||||||
}
|
}
|
||||||
needsCreation = true
|
needsCreation = true
|
||||||
@@ -187,7 +187,7 @@ func (c *Controller) Cleanup() error {
|
|||||||
|
|
||||||
for _, ctr := range containers {
|
for _, ctr := range containers {
|
||||||
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
err = c.client.ContainerStop(ctx, ctr.ID, dockercontainer.StopOptions{})
|
||||||
if err != nil && !client.IsErrNotFound(err) {
|
if err != nil && !errdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
|
errs = append(errs, fmt.Errorf("stop container '%s': %w", ctr.ID, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,7 +197,7 @@ func (c *Controller) Cleanup() error {
|
|||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
removed++
|
removed++
|
||||||
} else if !client.IsErrNotFound(err) {
|
} else if !errdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, err))
|
errs = append(errs, fmt.Errorf("remove container '%s': %w", ctr.ID, err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -223,10 +223,10 @@ func (c *Controller) Cleanup() error {
|
|||||||
|
|
||||||
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
|
if err = c.client.NetworkRemove(ctx, NetworkName); err == nil {
|
||||||
slog.Info("Docker network removed.", "name", NetworkName)
|
slog.Info("Docker network removed.", "name", NetworkName)
|
||||||
} else if !client.IsErrNotFound(err) {
|
} else if !errdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
errs = append(errs, fmt.Errorf("remove Docker network '%s': %w", NetworkName, err))
|
||||||
}
|
}
|
||||||
} else if !client.IsErrNotFound(err) {
|
} else if !errdefs.IsNotFound(err) {
|
||||||
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
|
errs = append(errs, fmt.Errorf("inspect Docker network '%s': %w", NetworkName, err))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,25 @@
|
|||||||
package docker
|
package docker
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"maps"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"slices"
|
"slices"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
dockercommand "github.com/docker/cli/cli/command"
|
dockercommand "github.com/docker/cli/cli/command"
|
||||||
dockerconfig "github.com/docker/cli/cli/config"
|
dockerconfig "github.com/docker/cli/cli/config"
|
||||||
@@ -25,6 +31,7 @@ import (
|
|||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/stdcopy"
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/google/go-containerregistry/pkg/authn"
|
"github.com/google/go-containerregistry/pkg/authn"
|
||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
@@ -53,41 +60,35 @@ type Server struct {
|
|||||||
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
// internalDNSIP is a function that returns the IP address of the internal DNS server. It may return an empty
|
||||||
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
// address if the address is unknown (e.g. when the machine is not initialised yet).
|
||||||
internalDNSIP func() netip.Addr
|
internalDNSIP func() netip.Addr
|
||||||
|
// machineID is a function that returns the machine ID. It may return an empty string if the machine
|
||||||
|
// is not initialised yet.
|
||||||
|
machineID func() string
|
||||||
// networkReady is a function that returns true if the Docker network is ready for containers.
|
// networkReady is a function that returns true if the Docker network is ready for containers.
|
||||||
networkReady func() bool
|
networkReady func() bool
|
||||||
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
// waitForNetworkReady is a function that waits for the Docker network to be ready for containers.
|
||||||
waitForNetworkReady func(ctx context.Context) error
|
waitForNetworkReady func(ctx context.Context) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ServerOption configures the Docker server.
|
type ServerOptions struct {
|
||||||
type ServerOption func(*Server)
|
// TODO: verify if we still need the network readiness checks as the cluster controller ensures the network
|
||||||
|
// is ready before starting the network API server. It may still be needed when communicating with the local
|
||||||
// WithNetworkReady sets the network readiness check function.
|
// API server but in this case we should probably fail until the cluster is initialised.
|
||||||
func WithNetworkReady(networkReady func() bool) ServerOption {
|
NetworkReady func() bool
|
||||||
return func(s *Server) {
|
WaitForNetworkReady func(ctx context.Context) error
|
||||||
s.networkReady = networkReady
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WithWaitForNetworkReady sets the network readiness wait function.
|
|
||||||
func WithWaitForNetworkReady(waitForNetworkReady func(ctx context.Context) error) ServerOption {
|
|
||||||
return func(s *Server) {
|
|
||||||
s.waitForNetworkReady = waitForNetworkReady
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
// NewServer creates a new Docker gRPC server with the provided Docker service.
|
||||||
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, opts ...ServerOption) *Server {
|
func NewServer(service *Service, db *sqlx.DB, internalDNSIP func() netip.Addr, machineID func() string, opts ServerOptions) *Server {
|
||||||
s := &Server{
|
s := &Server{
|
||||||
client: service.Client,
|
client: service.Client,
|
||||||
service: service,
|
service: service,
|
||||||
db: db,
|
db: db,
|
||||||
internalDNSIP: internalDNSIP,
|
internalDNSIP: internalDNSIP,
|
||||||
|
machineID: machineID,
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, opt := range opts {
|
s.networkReady = opts.NetworkReady
|
||||||
opt(s)
|
s.waitForNetworkReady = opts.WaitForNetworkReady
|
||||||
}
|
|
||||||
|
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
@@ -115,7 +116,7 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
|||||||
|
|
||||||
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
resp, err := s.client.ContainerCreate(ctx, &config, &hostConfig, &networkConfig, &platform, req.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -133,7 +134,7 @@ func (s *Server) CreateContainer(ctx context.Context, req *pb.CreateContainerReq
|
|||||||
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
func (s *Server) InspectContainer(ctx context.Context, req *pb.InspectContainerRequest) (*pb.InspectContainerResponse, error) {
|
||||||
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
resp, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -166,7 +167,7 @@ func (s *Server) StartContainer(ctx context.Context, req *pb.StartContainerReque
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerStart(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -185,7 +186,7 @@ func (s *Server) StopContainer(ctx context.Context, req *pb.StopContainerRequest
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerStop(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -220,11 +221,11 @@ func (s *Server) ListContainers(ctx context.Context, req *pb.ListContainersReque
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
containers := make([]types.ContainerJSON, 0, len(containerSummaries))
|
containers := make([]container.InspectResponse, 0, len(containerSummaries))
|
||||||
for _, cs := range containerSummaries {
|
for _, cs := range containerSummaries {
|
||||||
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
c, err := s.client.ContainerInspect(ctx, cs.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
// The listed container may have been removed while we were inspecting other containers.
|
// The listed container may have been removed while we were inspecting other containers.
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -257,7 +258,7 @@ func (s *Server) RemoveContainer(ctx context.Context, req *pb.RemoveContainerReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
if err := s.client.ContainerRemove(ctx, req.Id, opts); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -324,9 +325,9 @@ func (s *Server) PullImage(req *pb.PullImageRequest, stream grpc.ServerStreaming
|
|||||||
|
|
||||||
// InspectImage returns the image information for the given image ID.
|
// InspectImage returns the image information for the given image ID.
|
||||||
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
func (s *Server) InspectImage(ctx context.Context, req *pb.InspectImageRequest) (*pb.InspectImageResponse, error) {
|
||||||
resp, _, err := s.client.ImageInspectWithRaw(ctx, req.Id)
|
resp, err := s.client.ImageInspect(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -385,6 +386,49 @@ func (s *Server) InspectRemoteImage(
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages returns a list of all images matching the filter and indicates whether Docker is using the containerd
|
||||||
|
// image store.
|
||||||
|
func (s *Server) ListImages(ctx context.Context, req *pb.ListImagesRequest) (*pb.ListImagesResponse, error) {
|
||||||
|
var opts image.ListOptions
|
||||||
|
if len(req.Options) > 0 {
|
||||||
|
if err := json.Unmarshal(req.Options, &opts); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle filters separately because they implement custom JSON unmarshalling.
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(req.Options, &raw); err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options to raw map: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if filtersBytes, ok := raw["Filters"]; ok {
|
||||||
|
args, err := filters.FromJSON(string(filtersBytes))
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Errorf(codes.InvalidArgument, "unmarshal filters: %v", err)
|
||||||
|
}
|
||||||
|
opts.Filters = args
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
images, err := s.service.ListImages(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
machineImages := pb.MachineImages{
|
||||||
|
ContainerdStore: images.ContainerdStore,
|
||||||
|
}
|
||||||
|
if len(images.Images) > 0 {
|
||||||
|
if machineImages.Images, err = json.Marshal(images.Images); err != nil {
|
||||||
|
return nil, status.Errorf(codes.Internal, "marshal Docker images: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &pb.ListImagesResponse{
|
||||||
|
Messages: []*pb.MachineImages{&machineImages},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreateVolume creates a new volume with the given options.
|
// CreateVolume creates a new volume with the given options.
|
||||||
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest) (*pb.CreateVolumeResponse, error) {
|
||||||
var opts volume.CreateOptions
|
var opts volume.CreateOptions
|
||||||
@@ -456,7 +500,7 @@ func (s *Server) ListVolumes(ctx context.Context, req *pb.ListVolumesRequest) (*
|
|||||||
// RemoveVolume removes a volume with the given ID.
|
// RemoveVolume removes a volume with the given ID.
|
||||||
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
func (s *Server) RemoveVolume(ctx context.Context, req *pb.RemoveVolumeRequest) (*emptypb.Empty, error) {
|
||||||
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
if err := s.client.VolumeRemove(ctx, req.Id, req.Force); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -492,9 +536,21 @@ func (s *Server) CreateServiceContainer(
|
|||||||
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
containerName = fmt.Sprintf("%s-%s", spec.Name, suffix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
envVars := maps.Clone(spec.Container.Env)
|
||||||
|
if envVars == nil {
|
||||||
|
envVars = make(api.EnvVars)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject the machine ID if available
|
||||||
|
if s.machineID != nil {
|
||||||
|
if machineID := s.machineID(); machineID != "" {
|
||||||
|
envVars["UNCLOUD_MACHINE_ID"] = machineID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
config := &container.Config{
|
config := &container.Config{
|
||||||
Cmd: spec.Container.Command,
|
Cmd: spec.Container.Command,
|
||||||
Env: spec.Container.Env.ToSlice(),
|
Env: envVars.ToSlice(),
|
||||||
Entrypoint: spec.Container.Entrypoint,
|
Entrypoint: spec.Container.Entrypoint,
|
||||||
Hostname: containerName,
|
Hostname: containerName,
|
||||||
Image: spec.Container.Image,
|
Image: spec.Container.Image,
|
||||||
@@ -557,6 +613,7 @@ func (s *Server) CreateServiceContainer(
|
|||||||
NanoCPUs: spec.Container.Resources.CPU,
|
NanoCPUs: spec.Container.Resources.CPU,
|
||||||
Memory: spec.Container.Resources.Memory,
|
Memory: spec.Container.Resources.Memory,
|
||||||
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
MemoryReservation: spec.Container.Resources.MemoryReservation,
|
||||||
|
DeviceRequests: spec.Container.Resources.DeviceReservations,
|
||||||
},
|
},
|
||||||
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
// Restart service containers if they exit or a machine restarts unless they are explicitly stopped.
|
||||||
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
// For one-off containers and batch jobs we plan to use a different service type/mode.
|
||||||
@@ -590,12 +647,19 @@ func (s *Server) CreateServiceContainer(
|
|||||||
|
|
||||||
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
resp, err := s.client.ContainerCreate(ctx, config, hostConfig, networkConfig, nil, containerName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject configs into the created container
|
||||||
|
if err = s.injectConfigs(ctx, resp.ID, spec.Configs, spec.Container.ConfigMounts); err != nil {
|
||||||
|
// Remove the container if config injection fails
|
||||||
|
_ = s.client.ContainerRemove(ctx, resp.ID, container.RemoveOptions{RemoveVolumes: true})
|
||||||
|
return nil, status.Errorf(codes.Internal, "inject configs: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
respBytes, err := json.Marshal(resp)
|
respBytes, err := json.Marshal(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
return nil, status.Errorf(codes.Internal, "marshal response: %v", err)
|
||||||
@@ -672,6 +736,122 @@ func ToDockerMounts(volumes []api.VolumeSpec, mounts []api.VolumeMount) ([]mount
|
|||||||
return dockerMounts, nil
|
return dockerMounts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// injectConfigs writes config content directly into the container.
|
||||||
|
// It processes ConfigSpecs and ConfigMounts to mount configuration content into the container filesystem.
|
||||||
|
func (s *Server) injectConfigs(ctx context.Context, containerID string, configs []api.ConfigSpec, mounts []api.ConfigMount) error {
|
||||||
|
if len(configs) == 0 || len(mounts) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := api.ValidateConfigsAndMounts(configs, mounts); err != nil {
|
||||||
|
return fmt.Errorf("validate configs and mounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of config name to config spec for quick lookup
|
||||||
|
configMap := make(map[string]api.ConfigSpec)
|
||||||
|
for _, config := range configs {
|
||||||
|
configMap[config.Name] = config
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each config mount
|
||||||
|
for _, m := range mounts {
|
||||||
|
config, exists := configMap[m.ConfigName]
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("config mount references a config that doesn't exist: '%s'", m.ConfigName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine target path in container
|
||||||
|
targetPath := m.ContainerPath
|
||||||
|
if targetPath == "" {
|
||||||
|
// This is the default from the Compose spec
|
||||||
|
targetPath = filepath.Join("/", m.ConfigName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine file mode
|
||||||
|
fileMode := os.FileMode(0o444) // Default permissions
|
||||||
|
if m.Mode != nil {
|
||||||
|
fileMode = *m.Mode
|
||||||
|
}
|
||||||
|
|
||||||
|
uid, err := m.GetNumericUid()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid Uid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gid, err := m.GetNumericGid()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid Gid: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the config content directly into the container
|
||||||
|
if err := s.copyContentToContainer(
|
||||||
|
ctx, containerID, config.Content, targetPath, uid, gid, fileMode,
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("copy config file '%s' to container: %w", config.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Injected config into container",
|
||||||
|
"config", config.Name,
|
||||||
|
"container", containerID[:12],
|
||||||
|
"target", targetPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// copyContentToContainer copies content directly to a file in the container using Docker's CopyToContainer API.
|
||||||
|
func (s *Server) copyContentToContainer(ctx context.Context, containerID string, content []byte, targetPath string, uid *uint64, gid *uint64, fileMode os.FileMode) error {
|
||||||
|
// Create a tar archive containing the file
|
||||||
|
var buf bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&buf)
|
||||||
|
|
||||||
|
// Create tar header
|
||||||
|
header := &tar.Header{
|
||||||
|
Name: filepath.Base(targetPath),
|
||||||
|
Size: int64(len(content)),
|
||||||
|
Mode: int64(fileMode),
|
||||||
|
ModTime: time.Now(),
|
||||||
|
Typeflag: tar.TypeReg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set ownership if specified
|
||||||
|
if uid != nil {
|
||||||
|
header.Uid = int(*uid)
|
||||||
|
}
|
||||||
|
if gid != nil {
|
||||||
|
header.Gid = int(*gid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write header and content to tar archive
|
||||||
|
if err := tw.WriteHeader(header); err != nil {
|
||||||
|
return fmt.Errorf("write tar header: %w", err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write(content); err != nil {
|
||||||
|
return fmt.Errorf("write content to tar: %w", err)
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close tar writer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy the tar archive to the container
|
||||||
|
targetDir := filepath.Dir(targetPath)
|
||||||
|
if targetDir == "." {
|
||||||
|
targetDir = "/"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.client.CopyToContainer(
|
||||||
|
ctx,
|
||||||
|
containerID,
|
||||||
|
targetDir,
|
||||||
|
&buf,
|
||||||
|
container.CopyToContainerOptions{CopyUIDGID: true},
|
||||||
|
); err != nil {
|
||||||
|
return fmt.Errorf("copy to container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
func toDockerBindOptions(opts *api.BindOptions) *mount.BindOptions {
|
||||||
if opts == nil {
|
if opts == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -703,7 +883,7 @@ func (s *Server) verifyDockerVolumesExist(ctx context.Context, mounts []mount.Mo
|
|||||||
|
|
||||||
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
// TODO: non-local volume drivers should likely be handled differently (needs proper investigation).
|
||||||
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
if _, err := s.client.VolumeInspect(ctx, m.Source); err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
return status.Errorf(codes.NotFound, "volume '%s' not found", m.Source)
|
||||||
}
|
}
|
||||||
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
return status.Errorf(codes.Internal, "inspect volume '%s': %v", m.Source, err.Error())
|
||||||
@@ -723,7 +903,7 @@ func (s *Server) InspectServiceContainer(
|
|||||||
) (*pb.ServiceContainer, error) {
|
) (*pb.ServiceContainer, error) {
|
||||||
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
serviceCtr, err := s.service.InspectServiceContainer(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -813,7 +993,7 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
|||||||
if !fullDockerIDRegex.MatchString(req.Id) {
|
if !fullDockerIDRegex.MatchString(req.Id) {
|
||||||
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
ctr, err := s.client.ContainerInspect(ctx, req.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if client.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return nil, status.Error(codes.NotFound, err.Error())
|
return nil, status.Error(codes.NotFound, err.Error())
|
||||||
}
|
}
|
||||||
return nil, status.Error(codes.Internal, err.Error())
|
return nil, status.Error(codes.Internal, err.Error())
|
||||||
@@ -834,3 +1014,258 @@ func (s *Server) RemoveServiceContainer(ctx context.Context, req *pb.RemoveConta
|
|||||||
|
|
||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// receiveExecConfig receives and validates the initial exec configuration from the stream.
|
||||||
|
func (s *Server) receiveExecConfig(stream pb.Docker_ExecContainerServer) (*pb.ExecConfig, api.ExecOptions, error) {
|
||||||
|
req, err := stream.Recv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "receive config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
execConfig := req.GetConfig()
|
||||||
|
if execConfig == nil {
|
||||||
|
return nil, api.ExecOptions{}, status.Error(codes.InvalidArgument, "first message must contain exec config")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unmarshal the Uncloud's execOpts
|
||||||
|
var execOpts api.ExecOptions
|
||||||
|
if err := json.Unmarshal(execConfig.Options, &execOpts); err != nil {
|
||||||
|
return nil, api.ExecOptions{}, status.Errorf(codes.InvalidArgument, "unmarshal exec config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return execConfig, execOpts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServerExecInput reads from the gRPC stream and writes to Docker stdin, handling resize requests.
|
||||||
|
func (s *Server) handleServerExecInput(
|
||||||
|
ctx context.Context,
|
||||||
|
stream pb.Docker_ExecContainerServer,
|
||||||
|
attachConn types.HijackedResponse,
|
||||||
|
execID string,
|
||||||
|
tty bool,
|
||||||
|
) error {
|
||||||
|
slog.Debug("Input goroutine started", "exec_id", execID, "tty", tty)
|
||||||
|
defer slog.Debug("Input goroutine exited", "exec_id", execID)
|
||||||
|
|
||||||
|
defer attachConn.CloseWrite()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
slog.Debug("Input goroutine context canceled via context", "exec_id", execID)
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := stream.Recv()
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, io.EOF):
|
||||||
|
slog.Debug("Input goroutine received EOF", "exec_id", execID)
|
||||||
|
return nil
|
||||||
|
case status.Code(err) == codes.Canceled:
|
||||||
|
// Can be the case when the output goroutine ends and the stream context is canceled.
|
||||||
|
slog.Debug("Input goroutine context canceled", "exec_id", execID)
|
||||||
|
return nil
|
||||||
|
case err == nil:
|
||||||
|
// continue processing
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("receive from stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch payload := req.Payload.(type) {
|
||||||
|
case *pb.ExecContainerRequest_Stdin:
|
||||||
|
if _, err := attachConn.Conn.Write(payload.Stdin); err != nil {
|
||||||
|
return fmt.Errorf("write to stdin: %w", err)
|
||||||
|
}
|
||||||
|
case *pb.ExecContainerRequest_Resize:
|
||||||
|
if tty {
|
||||||
|
resizeOpts := container.ResizeOptions{
|
||||||
|
Height: uint(payload.Resize.Height),
|
||||||
|
Width: uint(payload.Resize.Width),
|
||||||
|
}
|
||||||
|
if err := s.client.ContainerExecResize(ctx, execID, resizeOpts); err != nil {
|
||||||
|
slog.Warn("Failed to resize TTY", "err", err, "exec_id", execID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleServerExecOutput reads from Docker stdout/stderr and writes to the gRPC stream.
|
||||||
|
func (s *Server) handleServerExecOutput(
|
||||||
|
stream pb.Docker_ExecContainerServer,
|
||||||
|
attachResp types.HijackedResponse,
|
||||||
|
execID string,
|
||||||
|
tty bool,
|
||||||
|
) error {
|
||||||
|
slog.Debug("Output goroutine started", "exec_id", execID, "tty", tty)
|
||||||
|
defer slog.Debug("Output goroutine exited", "exec_id", execID)
|
||||||
|
|
||||||
|
if tty {
|
||||||
|
// In TTY mode, all output is stdout - copy directly to stream
|
||||||
|
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
||||||
|
_, err := io.Copy(stdoutWriter, attachResp.Reader)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return fmt.Errorf("copy tty output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
} else {
|
||||||
|
// In non-TTY mode, Docker multiplexes stdout/stderr with headers
|
||||||
|
// Use stdcopy to demultiplex
|
||||||
|
slog.Debug("Starting StdCopy for non-TTY", "exec_id", execID)
|
||||||
|
stdoutWriter := &grpcStreamWriter{stream: stream, isStderr: false}
|
||||||
|
stderrWriter := &grpcStreamWriter{stream: stream, isStderr: true}
|
||||||
|
|
||||||
|
written, err := stdcopy.StdCopy(stdoutWriter, stderrWriter, attachResp.Reader)
|
||||||
|
slog.Debug("StdCopy completed", "exec_id", execID, "bytes", written, "err", err)
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return fmt.Errorf("demultiplex docker output: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grpcStreamWriter is a writer that sends data to a gRPC stream as stdout or stderr.
|
||||||
|
type grpcStreamWriter struct {
|
||||||
|
stream pb.Docker_ExecContainerServer
|
||||||
|
isStderr bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *grpcStreamWriter) Write(p []byte) (n int, err error) {
|
||||||
|
data := make([]byte, len(p))
|
||||||
|
copy(data, p)
|
||||||
|
|
||||||
|
var resp *pb.ExecContainerResponse
|
||||||
|
if w.isStderr {
|
||||||
|
resp = &pb.ExecContainerResponse{
|
||||||
|
Payload: &pb.ExecContainerResponse_Stderr{Stderr: data},
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resp = &pb.ExecContainerResponse{
|
||||||
|
Payload: &pb.ExecContainerResponse_Stdout{Stdout: data},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.stream.Send(resp); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExecContainer executes a command in a running container with bidirectional streaming for stdin/stdout/stderr.
|
||||||
|
func (s *Server) ExecContainer(stream pb.Docker_ExecContainerServer) error {
|
||||||
|
slog.Debug("ExecContainer server-side called")
|
||||||
|
defer slog.Debug("ExecContainer server-side ended")
|
||||||
|
|
||||||
|
ctx := stream.Context()
|
||||||
|
|
||||||
|
// Receive and validate configuration
|
||||||
|
execConfig, execOpts, err := s.receiveExecConfig(stream)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to Docker's ExecOptions
|
||||||
|
dockerExecOpts := container.ExecOptions{
|
||||||
|
Cmd: execOpts.Command,
|
||||||
|
AttachStdin: execOpts.AttachStdin,
|
||||||
|
AttachStdout: execOpts.AttachStdout,
|
||||||
|
AttachStderr: execOpts.AttachStderr,
|
||||||
|
Tty: execOpts.Tty,
|
||||||
|
User: execOpts.User,
|
||||||
|
Privileged: execOpts.Privileged,
|
||||||
|
WorkingDir: execOpts.WorkingDir,
|
||||||
|
Env: execOpts.Env,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the exec instance
|
||||||
|
execResp, err := s.client.ContainerExecCreate(ctx, execConfig.ContainerId, dockerExecOpts)
|
||||||
|
if err != nil {
|
||||||
|
if errdefs.IsNotFound(err) {
|
||||||
|
return status.Error(codes.NotFound, err.Error())
|
||||||
|
}
|
||||||
|
return status.Errorf(codes.Internal, "create exec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the exec ID back to the client
|
||||||
|
if err := stream.Send(&pb.ExecContainerResponse{
|
||||||
|
Payload: &pb.ExecContainerResponse_ExecId{ExecId: execResp.ID},
|
||||||
|
}); err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "send exec ID: %v", err)
|
||||||
|
}
|
||||||
|
slog.Debug("Sent exec ID to the client", "exec_id", execResp.ID)
|
||||||
|
|
||||||
|
// For detached mode, start without attaching
|
||||||
|
if execOpts.Detach {
|
||||||
|
dockerStartOpts := container.ExecStartOptions{
|
||||||
|
Tty: dockerExecOpts.Tty,
|
||||||
|
Detach: true,
|
||||||
|
}
|
||||||
|
if err := s.client.ContainerExecStart(ctx, execResp.ID, dockerStartOpts); err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "start exec: %v", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// For attached mode, attach to the exec instance
|
||||||
|
attachOpts := container.ExecAttachOptions{
|
||||||
|
Tty: dockerExecOpts.Tty,
|
||||||
|
}
|
||||||
|
attachConn, err := s.client.ContainerExecAttach(ctx, execResp.ID, attachOpts)
|
||||||
|
if err != nil {
|
||||||
|
return status.Errorf(codes.Internal, "attach to exec: %v", err)
|
||||||
|
}
|
||||||
|
defer attachConn.Close()
|
||||||
|
|
||||||
|
// Create a cancelable context for the input handler
|
||||||
|
handlerCtx, cancelInput := context.WithCancel(ctx)
|
||||||
|
defer cancelInput()
|
||||||
|
|
||||||
|
// Create a channel to wait for output completion
|
||||||
|
outputDone := make(chan error, 1)
|
||||||
|
|
||||||
|
// Start stdin handler if stdin is attached
|
||||||
|
if dockerExecOpts.AttachStdin {
|
||||||
|
go func() {
|
||||||
|
err := s.handleServerExecInput(handlerCtx, stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Error in exec input handler", "err", err, "exec_id", execResp.ID)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
// If not attaching stdin, close the write side immediately
|
||||||
|
attachConn.CloseWrite()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start output handler
|
||||||
|
// We only wait for this goroutine to complete - it signals when the exec process finishes
|
||||||
|
go func() {
|
||||||
|
outputDone <- s.handleServerExecOutput(stream, attachConn, execResp.ID, dockerExecOpts.Tty)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the output goroutine to complete (it signals when done)
|
||||||
|
// We only wait for the output handler goroutine, not for the stdin one.
|
||||||
|
if err := <-outputDone; err != nil {
|
||||||
|
slog.Warn("Error in exec output handler", "err", err, "exec_id", execResp.ID)
|
||||||
|
}
|
||||||
|
// Do a best-effort cancellation of the stdin handler.
|
||||||
|
// We can't guarantee immediate exit because it may be blocked on stream.Recv(), but at
|
||||||
|
// least we want to send a cancel signal explicitly.
|
||||||
|
cancelInput()
|
||||||
|
|
||||||
|
inspectResp, err := s.client.ContainerExecInspect(ctx, execResp.ID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to inspect exec after completion", "err", err, "exec_id", execResp.ID)
|
||||||
|
return status.Errorf(codes.Internal, "inspect exec: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send the exit code
|
||||||
|
slog.Debug("Sending exec exit code", "exec_id", execResp.ID, "exit_code", inspectResp.ExitCode)
|
||||||
|
if err := stream.Send(&pb.ExecContainerResponse{
|
||||||
|
Payload: &pb.ExecContainerResponse_ExitCode{ExitCode: int32(inspectResp.ExitCode)},
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("Failed to send exec exit code", "err", err, "exec_id", execResp.ID)
|
||||||
|
return status.Errorf(codes.Internal, "send exit code: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
"github.com/docker/docker/client"
|
"github.com/docker/docker/client"
|
||||||
"github.com/jmoiron/sqlx"
|
"github.com/jmoiron/sqlx"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
// Service provides higher-level Docker operations that extends Docker API with Uncloud-specific data
|
||||||
// from the machine database.
|
// from the machine database.
|
||||||
type Service struct {
|
type Service struct {
|
||||||
|
// Client is a Docker client for managing Docker resources.
|
||||||
Client *client.Client
|
Client *client.Client
|
||||||
db *sqlx.DB
|
// db is a connection to the machine database.
|
||||||
|
db *sqlx.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewService creates a new Docker service instance.
|
// NewService creates a new Docker service instance.
|
||||||
@@ -43,7 +49,7 @@ func (s *Service) InspectServiceContainer(ctx context.Context, nameOrID string)
|
|||||||
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
|
return serviceCtr, fmt.Errorf("container '%s' is not managed by Uncloud", nameOrID)
|
||||||
}
|
}
|
||||||
|
|
||||||
serviceCtr.Container = api.Container{ContainerJSON: ctr}
|
serviceCtr.Container = api.Container{InspectResponse: ctr}
|
||||||
|
|
||||||
// Retrieve ServiceSpec from the machine database.
|
// Retrieve ServiceSpec from the machine database.
|
||||||
var specBytes []byte
|
var specBytes []byte
|
||||||
@@ -101,3 +107,46 @@ func (s *Service) ListServiceContainers(
|
|||||||
|
|
||||||
return containers, nil
|
return containers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsContainerdImageStoreEnabled checks if Docker is configured to use the containerd image store:
|
||||||
|
// https://docs.docker.com/engine/storage/containerd/
|
||||||
|
func (s *Service) IsContainerdImageStoreEnabled(ctx context.Context) (bool, error) {
|
||||||
|
info, err := s.Client.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get Docker info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Contains(fmt.Sprintf("%s", info.DriverStatus), "containerd.snapshotter"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type Images struct {
|
||||||
|
// Images is a list of images present in the Docker image store (either internal or containerd).
|
||||||
|
Images []image.Summary
|
||||||
|
// ContainerdStore indicates whether Docker is using the containerd image store.
|
||||||
|
ContainerdStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImages lists Docker images with the given options and indicates whether Docker is using the containerd
|
||||||
|
// image store. It always includes image manifests in the response if the store is containerd.
|
||||||
|
func (s *Service) ListImages(ctx context.Context, opts image.ListOptions) (Images, error) {
|
||||||
|
var imagesResp Images
|
||||||
|
|
||||||
|
// Always include the image manifests in the response.
|
||||||
|
opts.Manifests = true
|
||||||
|
images, err := s.Client.ImageList(ctx, opts)
|
||||||
|
if err != nil {
|
||||||
|
return imagesResp, status.Errorf(codes.Internal, "list images: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
isContainerdStore, err := s.IsContainerdImageStoreEnabled(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return imagesResp, status.Errorf(codes.Internal, "check if Docker uses containerd image store: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesResp = Images{
|
||||||
|
Images: images,
|
||||||
|
ContainerdStore: isContainerdStore,
|
||||||
|
}
|
||||||
|
|
||||||
|
return imagesResp, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package firewall
|
package firewall
|
||||||
|
|
||||||
import "fmt"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/netip"
|
||||||
|
)
|
||||||
|
|
||||||
// ConfigureIptablesChains is a stub for Darwin.
|
// ConfigureIptablesChains is a stub for Darwin.
|
||||||
func ConfigureIptablesChains() error {
|
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||||
return fmt.Errorf("not supported on Darwin")
|
return fmt.Errorf("not supported on Darwin")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package firewall
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"net/netip"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
// ConfigureIptablesChains sets up custom iptables chains and initial firewall rules for Uncloud networking.
|
||||||
func ConfigureIptablesChains() error {
|
func ConfigureIptablesChains(machineIP netip.Addr) error {
|
||||||
if err := createIptablesChains(); err != nil {
|
if err := createIptablesChains(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -27,10 +28,24 @@ func ConfigureIptablesChains() error {
|
|||||||
ipt6 := iptables.GetIptable(iptables.IPv6)
|
ipt6 := iptables.GetIptable(iptables.IPv6)
|
||||||
|
|
||||||
// Allow WireGuard traffic to the machine.
|
// Allow WireGuard traffic to the machine.
|
||||||
acceptWireGuardRule := []string{"-p", "udp", "--dport", strconv.Itoa(network.WireGuardPort), "-j", "ACCEPT"}
|
acceptWireGuardRule := []string{
|
||||||
err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, acceptWireGuardRule)
|
"-p", "udp",
|
||||||
if err != nil {
|
"--dport", strconv.Itoa(network.WireGuardPort),
|
||||||
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(acceptWireGuardRule, " "), err)
|
"-j", "ACCEPT",
|
||||||
|
}
|
||||||
|
// Allow cluster machines to access the unregistry (embedded image registry) on the machine to push/pull images.
|
||||||
|
// TODO: allow access only from the machine IPs (10.210.N.1) but not the containers running on them. Use ipset?
|
||||||
|
acceptUnregistryRule := []string{
|
||||||
|
"-i", network.WireGuardInterfaceName,
|
||||||
|
"-d", machineIP.String(),
|
||||||
|
"-p", "tcp",
|
||||||
|
"--dport", strconv.Itoa(constants.UnregistryPort),
|
||||||
|
"-j", "ACCEPT",
|
||||||
|
}
|
||||||
|
for _, rule := range [][]string{acceptUnregistryRule, acceptWireGuardRule} {
|
||||||
|
if err := ipt4.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||||
|
return fmt.Errorf("insert iptables rule '%s': %w", strings.Join(rule, " "), err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
|
// Allow cluster machines to access Machine API via the management IPv6 WireGuard network.
|
||||||
@@ -50,7 +65,7 @@ func ConfigureIptablesChains() error {
|
|||||||
"-j", "ACCEPT",
|
"-j", "ACCEPT",
|
||||||
}
|
}
|
||||||
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
|
for _, rule := range [][]string{acceptMachineAPIRule, acceptCorrosionGossipRule} {
|
||||||
if err = ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
if err := ipt6.ProgramRule(iptables.Filter, UncloudInputChain, iptables.Insert, rule); err != nil {
|
||||||
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
|
return fmt.Errorf("insert ip6tables rule '%s': %w", strings.Join(rule, " "), err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-18
@@ -64,6 +64,8 @@ type Config struct {
|
|||||||
|
|
||||||
// DockerClient manages system and user containers using the local Docker daemon.
|
// DockerClient manages system and user containers using the local Docker daemon.
|
||||||
DockerClient *client.Client
|
DockerClient *client.Client
|
||||||
|
// ContainerdSockPath is the path to the containerd.sock used by Docker.
|
||||||
|
ContainerdSockPath string
|
||||||
|
|
||||||
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
|
// CaddyConfigDir specifies the directory where the machine generates the Caddy reverse proxy configuration file
|
||||||
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
// for routing external traffic to service containers across the internal network. Default is DataDir/caddy.
|
||||||
@@ -94,6 +96,26 @@ func (c *Config) SetDefaults() (*Config, error) {
|
|||||||
}
|
}
|
||||||
cfg.DockerClient = cli
|
cfg.DockerClient = cli
|
||||||
}
|
}
|
||||||
|
if cfg.ContainerdSockPath == "" {
|
||||||
|
// Auto-detect the containerd.sock path used by Docker.
|
||||||
|
paths := []string{
|
||||||
|
"/run/containerd/containerd.sock", // Default path on most Linux distributions.
|
||||||
|
"/run/docker/containerd/containerd.sock",
|
||||||
|
"/var/run/containerd/containerd.sock",
|
||||||
|
"/var/run/docker/containerd/containerd.sock",
|
||||||
|
}
|
||||||
|
for _, path := range paths {
|
||||||
|
if _, err := os.Stat(path); err == nil {
|
||||||
|
cfg.ContainerdSockPath = path
|
||||||
|
slog.Debug("Detected containerd socket used by Docker.", "path", path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.ContainerdSockPath == "" {
|
||||||
|
slog.Warn("Failed to auto-detect containerd socket used by Docker.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.CorrosionDir == "" {
|
if cfg.CorrosionDir == "" {
|
||||||
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
cfg.CorrosionDir = filepath.Join(cfg.DataDir, "corrosion")
|
||||||
@@ -232,7 +254,6 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("init machine database: %w", err)
|
return nil, fmt.Errorf("init machine database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
dockerService := machinedocker.NewService(config.DockerClient, db)
|
dockerService := machinedocker.NewService(config.DockerClient, db)
|
||||||
|
|
||||||
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
// Init a local gRPC proxy server that proxies requests to the local or remote machine API servers.
|
||||||
@@ -261,9 +282,14 @@ func NewMachine(config *Config) (*Machine, error) {
|
|||||||
internalDNSIP := func() netip.Addr {
|
internalDNSIP := func() netip.Addr {
|
||||||
return m.IP()
|
return m.IP()
|
||||||
}
|
}
|
||||||
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP,
|
// Machine ID will only be available after the machine is initialised as a cluster member so wrap it in a function.
|
||||||
machinedocker.WithNetworkReady(m.IsNetworkReady),
|
machineID := func() string {
|
||||||
machinedocker.WithWaitForNetworkReady(m.WaitForNetworkReady))
|
return m.state.ID
|
||||||
|
}
|
||||||
|
m.dockerServer = machinedocker.NewServer(dockerService, db, internalDNSIP, machineID, machinedocker.ServerOptions{
|
||||||
|
NetworkReady: m.IsNetworkReady,
|
||||||
|
WaitForNetworkReady: m.WaitForNetworkReady,
|
||||||
|
})
|
||||||
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
caddyServer := caddyconfig.NewServer(caddyconfig.NewService(config.CaddyConfigDir))
|
||||||
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
m.localMachineServer = newGRPCServer(m, c, m.dockerServer, caddyServer)
|
||||||
|
|
||||||
@@ -404,23 +430,41 @@ func (m *Machine) Run(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dnsResolver := dns.NewClusterResolver(m.store)
|
dnsResolver := dns.NewClusterResolver(m.store)
|
||||||
dnsServer, err := dns.NewServer(m.IP(), dnsResolver, m.config.DNSUpstreams)
|
dnsServer, err := dns.NewServer(
|
||||||
|
m.IP(),
|
||||||
|
m.state.Network.Subnet,
|
||||||
|
dnsResolver,
|
||||||
|
m.config.DNSUpstreams,
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("create embedded DNS server: %w", err)
|
return fmt.Errorf("create embedded DNS server: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create an embedded container registry listening on the machine IP address and
|
var unreg *unregistry.Registry
|
||||||
// using the local Docker (containerd) image store as its backend.
|
if m.config.ContainerdSockPath != "" {
|
||||||
unreg, err := unregistry.NewRegistry(unregistry.Config{
|
isContainerdStore, err := m.dockerService.IsContainerdImageStoreEnabled(ctx)
|
||||||
Addr: net.JoinHostPort(m.IP().String(), strconv.Itoa(constants.UnregistryPort)),
|
if err != nil {
|
||||||
ContainerdNamespace: "moby",
|
return fmt.Errorf("check if Docker uses containerd image store: %w", err)
|
||||||
// TODO: auto-detect the containerd.sock path used by Docker and/or allow configuring it.
|
}
|
||||||
ContainerdSock: "/run/containerd/containerd.sock",
|
|
||||||
LogFormatter: "text",
|
if isContainerdStore {
|
||||||
LogLevel: "info",
|
// Create an embedded container registry listening on the machine IP address and
|
||||||
})
|
// using the local Docker (containerd) image store as its backend.
|
||||||
if err != nil {
|
unreg, err = unregistry.NewRegistry(unregistry.Config{
|
||||||
return fmt.Errorf("create embedded registry: %w", err)
|
Addr: net.JoinHostPort(m.IP().String(), strconv.Itoa(constants.UnregistryPort)),
|
||||||
|
ContainerdNamespace: "moby",
|
||||||
|
ContainerdSock: m.config.ContainerdSockPath,
|
||||||
|
LogFormatter: "text",
|
||||||
|
LogLevel: "info",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create embedded registry: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("Skipping embedded unregistry setup as Docker is not using the containerd image store.")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slog.Warn("Skipping embedded unregistry setup as the containerd socket path is not configured.")
|
||||||
}
|
}
|
||||||
|
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -777,7 +821,14 @@ func (m *Machine) JoinCluster(_ context.Context, req *pb.JoinClusterRequest) (*e
|
|||||||
if err := m.state.Save(); err != nil {
|
if err := m.state.Save(); err != nil {
|
||||||
return nil, status.Errorf(codes.Internal, "save machine state: %v", err)
|
return nil, status.Errorf(codes.Internal, "save machine state: %v", err)
|
||||||
}
|
}
|
||||||
slog.Info("Machine configured to join the cluster.", "id", m.state.ID, "name", m.state.Name)
|
slog.Info(
|
||||||
|
"Machine configured to join the cluster.",
|
||||||
|
"id", m.state.ID,
|
||||||
|
"name", m.state.Name,
|
||||||
|
"subnet", m.state.Network.Subnet.String(),
|
||||||
|
"management_ip", m.state.Network.ManagementIP.String(),
|
||||||
|
"peers", len(m.state.Network.Peers),
|
||||||
|
)
|
||||||
// Signal that the machine is initialised as a member of a cluster.
|
// Signal that the machine is initialised as a member of a cluster.
|
||||||
m.initialised <- struct{}{}
|
m.initialised <- struct{}{}
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func (n *WireGuardNetwork) Configure(config Config) error {
|
|||||||
if err := n.configureDevice(config); err != nil {
|
if err := n.configureDevice(config); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
slog.Info("Configured WireGuard interface.", "name", n.link.Attrs().Name)
|
slog.Info("Configured WireGuard interface.", "name", n.link.Attrs().Name, "peers", len(n.peers))
|
||||||
|
|
||||||
managementPrefix, err := addrToSingleIPPrefix(config.ManagementIP)
|
managementPrefix, err := addrToSingleIPPrefix(config.ManagementIP)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ CREATE TABLE machines
|
|||||||
CREATE TABLE containers
|
CREATE TABLE containers
|
||||||
(
|
(
|
||||||
id TEXT NOT NULL PRIMARY KEY,
|
id TEXT NOT NULL PRIMARY KEY,
|
||||||
-- container is a JSON-serialized api.Container struct.
|
-- container is a JSON-serialized api.ServiceContainer struct.
|
||||||
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
|
container TEXT NOT NULL DEFAULT '{}' CHECK (json_valid(container)),
|
||||||
machine_id TEXT NOT NULL DEFAULT '',
|
machine_id TEXT NOT NULL DEFAULT '',
|
||||||
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
|
service_id TEXT AS (json_extract(container, '$.Config.Labels."uncloud.service.id"')),
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"os"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Proxy proxies local connections to a remote TCP address optionally using a custom dialer.
|
||||||
|
type Proxy struct {
|
||||||
|
Listener net.Listener
|
||||||
|
RemoteAddr string
|
||||||
|
DialContext func(ctx context.Context, network, address string) (net.Conn, error)
|
||||||
|
OnError func(error)
|
||||||
|
activeConns sync.WaitGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// deadliner is an interface for listeners that support setting deadlines.
|
||||||
|
type deadliner interface {
|
||||||
|
SetDeadline(t time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// halfCloser is an interface for connections that support half-close.
|
||||||
|
type halfCloser interface {
|
||||||
|
CloseWrite() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run starts the proxy and runs until the context is canceled.
|
||||||
|
func (p *Proxy) Run(ctx context.Context) {
|
||||||
|
if p.DialContext == nil {
|
||||||
|
p.DialContext = (&net.Dialer{}).DialContext
|
||||||
|
}
|
||||||
|
|
||||||
|
defer p.Listener.Close()
|
||||||
|
|
||||||
|
// Handle incoming connections until context is canceled.
|
||||||
|
Loop:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break Loop
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set a deadline on the listener if supported to check context periodically.
|
||||||
|
if dl, ok := p.Listener.(deadliner); ok {
|
||||||
|
dl.SetDeadline(time.Now().Add(1 * time.Second))
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := p.Listener.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if os.IsTimeout(err) {
|
||||||
|
// Just a timeout, continue to check context and accept again.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
break Loop
|
||||||
|
default:
|
||||||
|
if p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("accept local connection: %w", err))
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p.activeConns.Add(1)
|
||||||
|
go p.handleConnection(ctx, conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all connections to finish.
|
||||||
|
p.activeConns.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *Proxy) handleConnection(ctx context.Context, localConn net.Conn) {
|
||||||
|
defer p.activeConns.Done()
|
||||||
|
defer localConn.Close()
|
||||||
|
|
||||||
|
// Use a separate context with timeout for dialing the remote address.
|
||||||
|
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
remoteConn, err := p.DialContext(dialCtx, "tcp", p.RemoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
if p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("connect remote address '%s': %w", p.RemoteAddr, err))
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer remoteConn.Close()
|
||||||
|
|
||||||
|
// Bidirectional copy with proper half-close handling.
|
||||||
|
done := make(chan error, 2)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(remoteConn, localConn)
|
||||||
|
// Close write half of remote connection if supported.
|
||||||
|
if hc, ok := remoteConn.(halfCloser); ok {
|
||||||
|
hc.CloseWrite()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
_, err := io.Copy(localConn, remoteConn)
|
||||||
|
// Close write half of local connection if supported.
|
||||||
|
if hc, ok := localConn.(halfCloser); ok {
|
||||||
|
hc.CloseWrite()
|
||||||
|
}
|
||||||
|
done <- err
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for both copies to complete or context cancel.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
// Close connections to abort ongoing copies.
|
||||||
|
localConn.Close()
|
||||||
|
remoteConn.Close()
|
||||||
|
return
|
||||||
|
case err = <-done:
|
||||||
|
if err != nil && p.OnError != nil {
|
||||||
|
p.OnError(fmt.Errorf("data copy: %w", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/filters"
|
"github.com/docker/docker/api/types/filters"
|
||||||
"github.com/docker/docker/api/types/network"
|
"github.com/docker/docker/api/types/network"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
@@ -142,7 +142,7 @@ func (p *Provisioner) initCluster(ctx context.Context, machines []Machine) error
|
|||||||
|
|
||||||
// Join the rest of the machines to the cluster.
|
// Join the rest of the machines to the cluster.
|
||||||
for _, m := range machines[1:] {
|
for _, m := range machines[1:] {
|
||||||
if err = WaitMachineReady(ctx, m, 5*time.Second); err != nil {
|
if err = WaitMachineReady(ctx, m, 30*time.Second); err != nil {
|
||||||
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
|
return fmt.Errorf("wait for machine %q to be ready: %w", m.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +200,7 @@ func (p *Provisioner) InspectCluster(ctx context.Context, name string) (Cluster,
|
|||||||
// Docker network name is the same as the cluster name.
|
// Docker network name is the same as the cluster name.
|
||||||
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
|
net, err := p.dockerCli.NetworkInspect(ctx, name, network.InspectOptions{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return c, ErrNotFound
|
return c, ErrNotFound
|
||||||
}
|
}
|
||||||
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
|
return c, fmt.Errorf("inspect Docker network '%s': %w", name, err)
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/cenkalti/backoff/v4"
|
"github.com/cenkalti/backoff/v4"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/image"
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/go-connections/nat"
|
"github.com/docker/go-connections/nat"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
@@ -137,7 +137,7 @@ func (p *Provisioner) createContainerWithImagePull(
|
|||||||
return resp, nil
|
return resp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if !dockerclient.IsErrNotFound(err) {
|
if !errdefs.IsNotFound(err) {
|
||||||
return resp, fmt.Errorf("create Docker container: %w", err)
|
return resp, fmt.Errorf("create Docker container: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+22
-7
@@ -3,7 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"strings"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
@@ -28,6 +28,7 @@ type ContainerClient interface {
|
|||||||
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
RemoveContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.RemoveOptions) error
|
||||||
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
StartContainer(ctx context.Context, serviceNameOrID, containerNameOrID string) error
|
||||||
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
StopContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, opts container.StopOptions) error
|
||||||
|
ExecContainer(ctx context.Context, serviceNameOrID, containerNameOrID string, config ExecOptions) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type DNSClient interface {
|
type DNSClient interface {
|
||||||
@@ -63,21 +64,35 @@ type VolumeClient interface {
|
|||||||
func ProxyMachinesContext(
|
func ProxyMachinesContext(
|
||||||
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
ctx context.Context, cli MachineClient, namesOrIDs []string,
|
||||||
) (context.Context, MachineMembersList, error) {
|
) (context.Context, MachineMembersList, error) {
|
||||||
|
// TODO: move the machine IP resolution to the proxy router to allow setting machine names and IDs in the metadata.
|
||||||
machines, err := cli.ListMachines(ctx, nil)
|
machines, err := cli.ListMachines(ctx, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("list machines: %w", err)
|
return nil, nil, fmt.Errorf("list machines: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var proxiedMachines MachineMembersList
|
var proxiedMachines MachineMembersList
|
||||||
md := metadata.New(nil)
|
var notFound []string
|
||||||
for _, m := range machines {
|
for _, nameOrID := range namesOrIDs {
|
||||||
if len(namesOrIDs) == 0 ||
|
if m := machines.FindByNameOrID(nameOrID); m != nil {
|
||||||
slices.Contains(namesOrIDs, m.Machine.Name) || slices.Contains(namesOrIDs, m.Machine.Id) {
|
|
||||||
proxiedMachines = append(proxiedMachines, m)
|
proxiedMachines = append(proxiedMachines, m)
|
||||||
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
} else {
|
||||||
md.Append("machines", machineIP.String())
|
notFound = append(notFound, nameOrID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(notFound) > 0 {
|
||||||
|
return nil, nil, fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(namesOrIDs) == 0 {
|
||||||
|
proxiedMachines = machines
|
||||||
|
}
|
||||||
|
|
||||||
|
md := metadata.New(nil)
|
||||||
|
for _, m := range proxiedMachines {
|
||||||
|
machineIP, _ := m.Machine.Network.ManagementIp.ToAddr()
|
||||||
|
md.Append("machines", machineIP.String())
|
||||||
|
}
|
||||||
|
|
||||||
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
return metadata.NewOutgoingContext(ctx, md), proxiedMachines, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Implementation of Config feature from the Compose spec
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigSpec defines a configuration object that can be mounted into containers
|
||||||
|
type ConfigSpec struct {
|
||||||
|
Name string
|
||||||
|
|
||||||
|
// Content of the config when specified inline
|
||||||
|
Content []byte `json:",omitempty"`
|
||||||
|
|
||||||
|
// Note: NOT IMPLEMENTED
|
||||||
|
// External indicates this config already exists and should not be created
|
||||||
|
// External bool `json:",omitempty"`
|
||||||
|
|
||||||
|
// Note: NOT IMPLEMENTED
|
||||||
|
// Labels for the config
|
||||||
|
// Labels map[string]string `json:",omitempty"`
|
||||||
|
|
||||||
|
// TODO: add support for "environment"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigSpec) Validate() error {
|
||||||
|
if c.Name == "" {
|
||||||
|
return fmt.Errorf("config name is required")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equals compares two ConfigSpec instances
|
||||||
|
func (c *ConfigSpec) Equals(other ConfigSpec) bool {
|
||||||
|
return c.Name == other.Name &&
|
||||||
|
bytes.Equal(c.Content, other.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConfigMount defines how a config is mounted into a container
|
||||||
|
type ConfigMount struct {
|
||||||
|
// ConfigName references a config defined in ServiceSpec.Configs by its Name field
|
||||||
|
ConfigName string
|
||||||
|
// ContainerPath is the absolute path where the config is mounted in the container
|
||||||
|
ContainerPath string `json:",omitempty"`
|
||||||
|
// Uid for the mounted config file
|
||||||
|
Uid string `json:",omitempty"`
|
||||||
|
// Gid for the mounted config file
|
||||||
|
Gid string `json:",omitempty"`
|
||||||
|
// Mode (file permissions) for the mounted config file
|
||||||
|
Mode *os.FileMode `json:",omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) GetNumericUid() (*uint64, error) {
|
||||||
|
if c.Uid == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
uid, err := strconv.ParseUint(c.Uid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Uid '%s': %w", c.Uid, err)
|
||||||
|
}
|
||||||
|
if int(uid) < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Uid '%s': value too high", c.Uid)
|
||||||
|
}
|
||||||
|
return &uid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) GetNumericGid() (*uint64, error) {
|
||||||
|
if c.Gid == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
gid, err := strconv.ParseUint(c.Gid, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid Gid '%s': %w", c.Gid, err)
|
||||||
|
}
|
||||||
|
if int(gid) < 0 {
|
||||||
|
return nil, fmt.Errorf("invalid Gid '%s': value too high", c.Gid)
|
||||||
|
}
|
||||||
|
return &gid, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ConfigMount) Validate() error {
|
||||||
|
if c.ConfigName == "" {
|
||||||
|
return fmt.Errorf("config mount source is required")
|
||||||
|
}
|
||||||
|
if _, err := c.GetNumericUid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := c.GetNumericGid(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if c.ContainerPath != "" && !filepath.IsAbs(c.ContainerPath) {
|
||||||
|
return fmt.Errorf("container path must be absolute")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfigsAndMounts takes config specs and config mounts and validates that all mounts refer to existing specs
|
||||||
|
func ValidateConfigsAndMounts(configs []ConfigSpec, mounts []ConfigMount) error {
|
||||||
|
configMap := make(map[string]struct{})
|
||||||
|
for _, cfg := range configs {
|
||||||
|
if err := cfg.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid config: %w", err)
|
||||||
|
}
|
||||||
|
if _, ok := configMap[cfg.Name]; ok {
|
||||||
|
return fmt.Errorf("duplicate config name: '%s'", cfg.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
configMap[cfg.Name] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mount := range mounts {
|
||||||
|
if err := mount.Validate(); err != nil {
|
||||||
|
return fmt.Errorf("invalid config mount: %w", err)
|
||||||
|
}
|
||||||
|
if _, exists := configMap[mount.ConfigName]; !exists {
|
||||||
|
return fmt.Errorf("config mount source '%s' does not refer to any defined config", mount.ConfigName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// uint64Ptr is a convenience function to create a pointer to a uint64 value
|
||||||
|
func uint64Ptr(v uint64) *uint64 {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigMount_GetNumericUid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
uid string
|
||||||
|
expected *uint64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty uid returns nil",
|
||||||
|
uid: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid numeric uid",
|
||||||
|
uid: "1000",
|
||||||
|
expected: uint64Ptr(1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero uid",
|
||||||
|
uid: "0",
|
||||||
|
expected: uint64Ptr(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid non-numeric uid",
|
||||||
|
uid: "root",
|
||||||
|
wantErr: "invalid Uid 'root'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative uid",
|
||||||
|
uid: "-1",
|
||||||
|
wantErr: "invalid Uid",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "very large uid",
|
||||||
|
uid: "18446744073709551615", // max uint64
|
||||||
|
wantErr: "invalid Uid '18446744073709551615': value too high",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mount := &ConfigMount{Uid: tt.uid}
|
||||||
|
uid, err := mount.GetNumericUid()
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Nil(t, uid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expected == nil {
|
||||||
|
assert.Nil(t, uid)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, uid)
|
||||||
|
assert.Equal(t, *tt.expected, *uid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigMount_GetNumericGid(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
gid string
|
||||||
|
expected *uint64
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty gid returns nil",
|
||||||
|
gid: "",
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid numeric gid",
|
||||||
|
gid: "1000",
|
||||||
|
expected: uint64Ptr(1000),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero gid",
|
||||||
|
gid: "0",
|
||||||
|
expected: uint64Ptr(0),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid non-numeric gid",
|
||||||
|
gid: "wheel",
|
||||||
|
wantErr: "invalid Gid 'wheel'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative gid",
|
||||||
|
gid: "-1",
|
||||||
|
wantErr: "invalid Gid",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mount := &ConfigMount{Gid: tt.gid}
|
||||||
|
gid, err := mount.GetNumericGid()
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
assert.Nil(t, gid)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
if tt.expected == nil {
|
||||||
|
assert.Nil(t, gid)
|
||||||
|
} else {
|
||||||
|
require.NotNil(t, gid)
|
||||||
|
assert.Equal(t, *tt.expected, *gid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateConfigsAndMounts(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
mode := os.FileMode(0o644)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configs []ConfigSpec
|
||||||
|
mounts []ConfigMount
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty configs and mounts",
|
||||||
|
configs: []ConfigSpec{},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid configs without mounts",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config2", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid configs with valid mounts",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config2", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config1"},
|
||||||
|
{ConfigName: "config2", ContainerPath: "/etc/config2", Uid: "1000", Gid: "1000"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with empty name",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "", Content: []byte("content")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
wantErr: "config name is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate config names",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
{Name: "config1", Content: []byte("content2")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{},
|
||||||
|
wantErr: "duplicate config name: 'config1'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with empty config name",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "", ContainerPath: "/etc/config"},
|
||||||
|
},
|
||||||
|
wantErr: "config mount source is required",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount referencing non-existent config",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "nonexistent", ContainerPath: "/etc/config"},
|
||||||
|
},
|
||||||
|
wantErr: "config mount source 'nonexistent' does not refer to any defined config",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with invalid uid",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config", Uid: "invalid"},
|
||||||
|
},
|
||||||
|
wantErr: "invalid Uid 'invalid'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with invalid gid",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/etc/config", Gid: "invalid"},
|
||||||
|
},
|
||||||
|
wantErr: "invalid Gid 'invalid'",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with relative container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "relative/path"},
|
||||||
|
},
|
||||||
|
wantErr: "container path must be absolute",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with empty container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: ""},
|
||||||
|
},
|
||||||
|
// Empty path is allowed
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mount with absolute container path",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "config1", Content: []byte("content1")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "config1", ContainerPath: "/absolute/path"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex valid scenario",
|
||||||
|
configs: []ConfigSpec{
|
||||||
|
{Name: "nginx-conf", Content: []byte("server { listen 80; }")},
|
||||||
|
{Name: "app-config", Content: []byte("debug=true")},
|
||||||
|
{Name: "cert", Content: []byte("-----BEGIN CERTIFICATE-----")},
|
||||||
|
},
|
||||||
|
mounts: []ConfigMount{
|
||||||
|
{ConfigName: "nginx-conf", ContainerPath: "/etc/nginx/nginx.conf", Uid: "0", Gid: "0", Mode: &mode},
|
||||||
|
{ConfigName: "app-config", ContainerPath: "/app/config.env"},
|
||||||
|
{ConfigName: "cert", ContainerPath: "/etc/ssl/cert.pem", Uid: "1000", Gid: "1000"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
err := ValidateConfigsAndMounts(tt.configs, tt.mounts)
|
||||||
|
|
||||||
|
if tt.wantErr != "" {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.wantErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-4
@@ -7,7 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/pkg/stringid"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -23,7 +24,7 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Container struct {
|
type Container struct {
|
||||||
types.ContainerJSON
|
container.InspectResponse
|
||||||
// created caches the parsed creation time by CreatedTime.
|
// created caches the parsed creation time by CreatedTime.
|
||||||
created time.Time
|
created time.Time
|
||||||
}
|
}
|
||||||
@@ -52,7 +53,7 @@ func (c *Container) Healthy() bool {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.State.Health.Status == types.Healthy
|
return c.State.Health.Status == container.Healthy
|
||||||
}
|
}
|
||||||
|
|
||||||
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
// HumanState returns a human-readable description of the container's state. Based on the Docker implementation:
|
||||||
@@ -78,7 +79,7 @@ func (c *Container) HumanState() (string, error) {
|
|||||||
|
|
||||||
if c.State.Health != nil {
|
if c.State.Health != nil {
|
||||||
status := c.State.Health.Status
|
status := c.State.Health.Status
|
||||||
if status == types.Starting {
|
if status == container.Starting {
|
||||||
status = "health: " + status
|
status = "health: " + status
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,6 +145,11 @@ type ServiceContainer struct {
|
|||||||
ServiceSpec ServiceSpec
|
ServiceSpec ServiceSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ShortID returns the truncated ID of the container (12 characters).
|
||||||
|
func (c *ServiceContainer) ShortID() string {
|
||||||
|
return stringid.TruncateID(c.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// ServiceID returns the ID of the service this container belongs to.
|
// ServiceID returns the ID of the service this container belongs to.
|
||||||
func (c *ServiceContainer) ServiceID() string {
|
func (c *ServiceContainer) ServiceID() string {
|
||||||
return c.Config.Labels[LabelServiceID]
|
return c.Config.Labels[LabelServiceID]
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import "io"
|
||||||
|
|
||||||
|
// ExecOptions contains configuration for executing a command in a container.
|
||||||
|
type ExecOptions struct {
|
||||||
|
// Command is the command to run in the container.
|
||||||
|
Command []string
|
||||||
|
// AttachStdin attaches the stdin stream to the exec session.
|
||||||
|
AttachStdin bool
|
||||||
|
// AttachStdout attaches the stdout stream to the exec session.
|
||||||
|
AttachStdout bool
|
||||||
|
// AttachStderr attaches the stderr stream to the exec session.
|
||||||
|
AttachStderr bool
|
||||||
|
// Tty allocates a pseudo-TTY for the exec session.
|
||||||
|
Tty bool
|
||||||
|
// Detach runs the command in the background without attaching to streams.
|
||||||
|
Detach bool
|
||||||
|
|
||||||
|
//// Not yet implemented fields
|
||||||
|
// User specifies the user to run the command as.
|
||||||
|
User string
|
||||||
|
// Privileged runs the command in privileged mode.
|
||||||
|
Privileged bool
|
||||||
|
// WorkingDir sets the working directory for the command.
|
||||||
|
WorkingDir string
|
||||||
|
// Env sets environment variables for the command.
|
||||||
|
Env []string
|
||||||
|
|
||||||
|
// Client-side only fields (not serialized, not sent to server)
|
||||||
|
// Stdin is the input stream. Defaults to os.Stdin if nil.
|
||||||
|
Stdin io.Reader `json:"-"`
|
||||||
|
// Stdout is the output stream. Defaults to os.Stdout if nil.
|
||||||
|
Stdout io.Writer `json:"-"`
|
||||||
|
// Stderr is the error stream. Defaults to os.Stderr if nil.
|
||||||
|
Stderr io.Writer `json:"-"`
|
||||||
|
}
|
||||||
+30
-31
@@ -4,7 +4,6 @@ import (
|
|||||||
"net/netip"
|
"net/netip"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/docker/docker/api/types"
|
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@@ -15,9 +14,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("exited", func(t *testing.T) {
|
t.Run("exited", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: false,
|
Running: false,
|
||||||
Dead: false,
|
Dead: false,
|
||||||
ExitCode: 0,
|
ExitCode: 0,
|
||||||
@@ -29,9 +28,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running with no health check", func(t *testing.T) {
|
t.Run("running with no health check", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -41,12 +40,12 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running and healthy", func(t *testing.T) {
|
t.Run("running and healthy", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: types.Healthy,
|
Status: container.Healthy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,12 +55,12 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running but unhealthy", func(t *testing.T) {
|
t.Run("running but unhealthy", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: types.Unhealthy,
|
Status: container.Unhealthy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -71,11 +70,11 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("running with health starting", func(t *testing.T) {
|
t.Run("running with health starting", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Running: true,
|
Running: true,
|
||||||
Health: &types.Health{
|
Health: &container.Health{
|
||||||
Status: "starting",
|
Status: "starting",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -86,9 +85,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("dead", func(t *testing.T) {
|
t.Run("dead", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Dead: true,
|
Dead: true,
|
||||||
Running: false,
|
Running: false,
|
||||||
},
|
},
|
||||||
@@ -99,9 +98,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("restarting", func(t *testing.T) {
|
t.Run("restarting", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Restarting: true,
|
Restarting: true,
|
||||||
Running: true,
|
Running: true,
|
||||||
ExitCode: 1,
|
ExitCode: 1,
|
||||||
@@ -113,9 +112,9 @@ func TestContainer_Healthy(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("paused", func(t *testing.T) {
|
t.Run("paused", func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
c := &Container{ContainerJSON: types.ContainerJSON{
|
c := &Container{InspectResponse: container.InspectResponse{
|
||||||
ContainerJSONBase: &types.ContainerJSONBase{
|
ContainerJSONBase: &container.ContainerJSONBase{
|
||||||
State: &types.ContainerState{
|
State: &container.State{
|
||||||
Paused: true,
|
Paused: true,
|
||||||
Running: true,
|
Running: true,
|
||||||
},
|
},
|
||||||
@@ -280,7 +279,7 @@ func TestContainer_ConflictingServicePorts(t *testing.T) {
|
|||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
ctr := &ServiceContainer{Container: Container{ContainerJSON: types.ContainerJSON{
|
ctr := &ServiceContainer{Container: Container{InspectResponse: container.InspectResponse{
|
||||||
Config: &container.Config{
|
Config: &container.Config{
|
||||||
Labels: map[string]string{
|
Labels: map[string]string{
|
||||||
LabelServicePorts: tt.containerPorts,
|
LabelServicePorts: tt.containerPorts,
|
||||||
|
|||||||
+22
-2
@@ -2,14 +2,34 @@ package api
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/distribution/reference"
|
"github.com/distribution/reference"
|
||||||
"github.com/docker/docker/api/types"
|
"github.com/docker/docker/api/types/image"
|
||||||
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
v1 "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
)
|
)
|
||||||
|
|
||||||
type MachineImage struct {
|
type MachineImage struct {
|
||||||
Metadata *pb.Metadata
|
Metadata *pb.Metadata
|
||||||
Image types.ImageInspect
|
Image image.InspectResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
// MachineImages represents images present on a particular machine.
|
||||||
|
type MachineImages struct {
|
||||||
|
Metadata *pb.Metadata
|
||||||
|
// Images is a list of images present on the machine.
|
||||||
|
Images []image.Summary
|
||||||
|
// ContainerdStore indicates whether Docker on the machine uses the containerd image store
|
||||||
|
// (containerd-snapshotter feature).
|
||||||
|
ContainerdStore bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// ImageFilter defines criteria to filter images in ListImages.
|
||||||
|
type ImageFilter struct {
|
||||||
|
// Machines filters images to those present on the specified machines (names or IDs).
|
||||||
|
// If empty, it matches images on all machines.
|
||||||
|
Machines []string
|
||||||
|
// Name filters images by name (with or without tag). Accepts a wildcard pattern.
|
||||||
|
// If empty, it matches all image names.
|
||||||
|
Name string
|
||||||
}
|
}
|
||||||
|
|
||||||
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
// MachineRemoteImage represents an image in a remote registry fetched by a particular machine.
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
MilliCore = 1_000_000
|
MilliCore = 1_000_000
|
||||||
Core = 1000 * MilliCore
|
Core = 1000 * MilliCore
|
||||||
@@ -13,4 +17,6 @@ type ContainerResources struct {
|
|||||||
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
|
// MemoryReservation is the minimum amount of memory (in bytes) the container needs to run efficiently.
|
||||||
// TODO: implement a placement constraint that checks available memory on machines.
|
// TODO: implement a placement constraint that checks available memory on machines.
|
||||||
MemoryReservation int64
|
MemoryReservation int64
|
||||||
|
// Device reservations/requests for access to things like GPUs
|
||||||
|
DeviceReservations []container.DeviceRequest
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ type ServiceSpec struct {
|
|||||||
Replicas uint `json:",omitempty"`
|
Replicas uint `json:",omitempty"`
|
||||||
// Volumes is list of data volumes that can be mounted into the container.
|
// Volumes is list of data volumes that can be mounted into the container.
|
||||||
Volumes []VolumeSpec
|
Volumes []VolumeSpec
|
||||||
|
// Configs is list of configuration objects that can be mounted into the container.
|
||||||
|
Configs []ConfigSpec
|
||||||
}
|
}
|
||||||
|
|
||||||
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
// CaddyConfig returns the Caddy reverse proxy configuration for the service or an empty string if it's not defined.
|
||||||
@@ -79,6 +81,15 @@ func (s *ServiceSpec) Volume(name string) (VolumeSpec, bool) {
|
|||||||
return VolumeSpec{}, false
|
return VolumeSpec{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ServiceSpec) Config(name string) (ConfigSpec, bool) {
|
||||||
|
for _, c := range s.Configs {
|
||||||
|
if c.Name == name {
|
||||||
|
return c, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ConfigSpec{}, false
|
||||||
|
}
|
||||||
|
|
||||||
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
// MountedDockerVolumes returns the list of volumes of VolumeTypeVolume type that are mounted into the container.
|
||||||
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
func (s *ServiceSpec) MountedDockerVolumes() []VolumeSpec {
|
||||||
volumes := make(map[string]VolumeSpec)
|
volumes := make(map[string]VolumeSpec)
|
||||||
@@ -157,6 +168,7 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate volumes
|
||||||
volumeNames := make(map[string]struct{})
|
volumeNames := make(map[string]struct{})
|
||||||
for _, v := range s.Volumes {
|
for _, v := range s.Volumes {
|
||||||
if err := v.Validate(); err != nil {
|
if err := v.Validate(); err != nil {
|
||||||
@@ -177,6 +189,11 @@ func (s *ServiceSpec) Validate() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Validate configs
|
||||||
|
if err := ValidateConfigsAndMounts(s.Configs, s.Container.ConfigMounts); err != nil {
|
||||||
|
return fmt.Errorf("validate service configs and mounts: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,6 +247,9 @@ type ContainerSpec struct {
|
|||||||
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
// VolumeMounts specifies how volumes are mounted into the container filesystem.
|
||||||
// Each mount references a volume defined in ServiceSpec.Volumes.
|
// Each mount references a volume defined in ServiceSpec.Volumes.
|
||||||
VolumeMounts []VolumeMount
|
VolumeMounts []VolumeMount
|
||||||
|
// ConfigMounts specifies how configs are mounted into the container filesystem.
|
||||||
|
// Each mount references a config defined in ServiceSpec.Configs.
|
||||||
|
ConfigMounts []ConfigMount
|
||||||
// Volumes is list of data volumes to mount into the container.
|
// Volumes is list of data volumes to mount into the container.
|
||||||
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
// TODO(lhf): delete all usage, has been replaced with []VolumeMounts.
|
||||||
Volumes []string
|
Volumes []string
|
||||||
@@ -346,6 +366,15 @@ type MachineServiceContainer struct {
|
|||||||
Container ServiceContainer
|
Container ServiceContainer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Images returns a sorted list of unique images used by the service containers.
|
||||||
|
func (s *Service) Images() []string {
|
||||||
|
images := make(map[string]struct{})
|
||||||
|
for _, ctr := range s.Containers {
|
||||||
|
images[ctr.Container.Config.Image] = struct{}{}
|
||||||
|
}
|
||||||
|
return slices.Sorted(maps.Keys(images))
|
||||||
|
}
|
||||||
|
|
||||||
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
// Endpoints returns the exposed HTTP and HTTPS endpoints of the service.
|
||||||
func (s *Service) Endpoints() []string {
|
func (s *Service) Endpoints() []string {
|
||||||
endpoints := make(map[string]struct{})
|
endpoints := make(map[string]struct{})
|
||||||
|
|||||||
+1
-1
@@ -248,7 +248,7 @@ type VolumeFilter struct {
|
|||||||
Driver string
|
Driver string
|
||||||
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
// Labels filters volumes by label key-value pairs. Volumes must match all labels.
|
||||||
Labels map[string]string
|
Labels map[string]string
|
||||||
// MachineIDs filters volumes to those on the specified machines (names or IDs).
|
// Machines filters volumes to those on the specified machines (names or IDs).
|
||||||
Machines []string
|
Machines []string
|
||||||
// Names filters volumes by name. Volumes must match one of the names.
|
// Names filters volumes by name. Volumes must match one of the names.
|
||||||
Names []string
|
Names []string
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/metadata"
|
"google.golang.org/grpc/metadata"
|
||||||
)
|
)
|
||||||
@@ -31,9 +32,12 @@ type Client struct {
|
|||||||
|
|
||||||
var _ api.Client = (*Client)(nil)
|
var _ api.Client = (*Client)(nil)
|
||||||
|
|
||||||
// Connector is an interface for establishing a connection to the machine API.
|
// Connector is an interface for establishing a connection to the cluster.
|
||||||
type Connector interface {
|
type Connector interface {
|
||||||
|
// Connect establishes a gRPC client connection to the machine API.
|
||||||
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
Connect(ctx context.Context) (*grpc.ClientConn, error)
|
||||||
|
// Dialer returns a proxy dialer for establishing connections within the cluster if supported by the connector.
|
||||||
|
Dialer() (proxy.ContextDialer, error)
|
||||||
Close() error
|
Close() error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,6 +57,7 @@ func New(ctx context.Context, connector Connector) (*Client, error) {
|
|||||||
c.ClusterClient = pb.NewClusterClient(c.conn)
|
c.ClusterClient = pb.NewClusterClient(c.conn)
|
||||||
c.Caddy = pb.NewCaddyClient(c.conn)
|
c.Caddy = pb.NewCaddyClient(c.conn)
|
||||||
c.Docker = docker.NewClient(c.conn)
|
c.Docker = docker.NewClient(c.conn)
|
||||||
|
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TODO: add support for short syntax configs
|
||||||
|
func configSpecsFromCompose(
|
||||||
|
configs types.Configs, serviceConfigs []types.ServiceConfigObjConfig, workingDir string,
|
||||||
|
) ([]api.ConfigSpec, []api.ConfigMount, error) {
|
||||||
|
var configSpecs []api.ConfigSpec
|
||||||
|
var configMounts []api.ConfigMount
|
||||||
|
|
||||||
|
for _, serviceConfig := range serviceConfigs {
|
||||||
|
var spec api.ConfigSpec
|
||||||
|
|
||||||
|
if projectConfig, exists := configs[serviceConfig.Source]; exists {
|
||||||
|
if projectConfig.External {
|
||||||
|
return nil, nil, fmt.Errorf("external configs are not supported: %s",
|
||||||
|
serviceConfig.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
spec = api.ConfigSpec{
|
||||||
|
Name: serviceConfig.Source,
|
||||||
|
Content: []byte(projectConfig.Content),
|
||||||
|
}
|
||||||
|
|
||||||
|
// If File is specified, read the file contents
|
||||||
|
if projectConfig.File != "" {
|
||||||
|
configPath := projectConfig.File
|
||||||
|
// TODO: handle this in a separate function?
|
||||||
|
if !filepath.IsAbs(configPath) {
|
||||||
|
configPath = filepath.Join(workingDir, configPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := os.ReadFile(configPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("read config from file '%s': %w", projectConfig.File, err)
|
||||||
|
}
|
||||||
|
spec.Content = fileContent
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return nil, nil, fmt.Errorf("config '%s' not found in project configs", serviceConfig.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
configSpecs = append(configSpecs, spec)
|
||||||
|
|
||||||
|
// Create config mount
|
||||||
|
target := serviceConfig.Target
|
||||||
|
if target == "" {
|
||||||
|
target = "/" + serviceConfig.Source // Default mount path
|
||||||
|
}
|
||||||
|
|
||||||
|
mount := api.ConfigMount{
|
||||||
|
ConfigName: spec.Name,
|
||||||
|
ContainerPath: target,
|
||||||
|
Uid: serviceConfig.UID,
|
||||||
|
Gid: serviceConfig.GID,
|
||||||
|
}
|
||||||
|
|
||||||
|
if serviceConfig.Mode != nil {
|
||||||
|
mode := os.FileMode(*serviceConfig.Mode)
|
||||||
|
mount.Mode = &mode
|
||||||
|
}
|
||||||
|
|
||||||
|
configMounts = append(configMounts, mount)
|
||||||
|
}
|
||||||
|
|
||||||
|
return configSpecs, configMounts, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigSpecsFromCompose(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
configs types.Configs
|
||||||
|
serviceConfigs []types.ServiceConfigObjConfig
|
||||||
|
expectedSpecs []api.ConfigSpec
|
||||||
|
expectedMounts []api.ConfigMount
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "project-level config with file",
|
||||||
|
configs: types.Configs{
|
||||||
|
"app-config": types.ConfigObjConfig{
|
||||||
|
File: "testdata/config1.txt",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||||
|
{
|
||||||
|
Source: "app-config",
|
||||||
|
Target: "/app/config.json",
|
||||||
|
UID: "1000",
|
||||||
|
GID: "1000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSpecs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "app-config",
|
||||||
|
Content: []byte("test config content\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "app-config",
|
||||||
|
ContainerPath: "/app/config.json",
|
||||||
|
Uid: "1000",
|
||||||
|
Gid: "1000",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "config with mode",
|
||||||
|
configs: types.Configs{
|
||||||
|
"nginx-config": types.ConfigObjConfig{
|
||||||
|
File: "./testdata/nginx.conf",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
serviceConfigs: []types.ServiceConfigObjConfig{
|
||||||
|
{
|
||||||
|
Source: "nginx-config",
|
||||||
|
Target: "/etc/nginx/nginx.conf",
|
||||||
|
Mode: func() *types.FileMode { m := types.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedSpecs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "nginx-config",
|
||||||
|
Content: []byte("user nginx;\nworker_processes auto;\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expectedMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "nginx-config",
|
||||||
|
ContainerPath: "/etc/nginx/nginx.conf",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
configSpecs, configMounts, err := configSpecsFromCompose(tt.configs, tt.serviceConfigs, ".")
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.ElementsMatch(t, tt.expectedSpecs, configSpecs)
|
||||||
|
assert.Equal(t, tt.expectedMounts, configMounts)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigSpecEquals(t *testing.T) {
|
||||||
|
config1 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
}
|
||||||
|
|
||||||
|
config2 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
}
|
||||||
|
|
||||||
|
config3 := api.ConfigSpec{
|
||||||
|
Name: "test-config",
|
||||||
|
Content: []byte("some content"),
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.True(t, config1.Equals(config2))
|
||||||
|
assert.False(t, config1.Equals(config3))
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"text/template"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/distribution/reference"
|
||||||
|
"github.com/psviderski/uncloud/internal/gitutil"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DefaultTagTemplate is the default tag template appended to images without a tag when building locally.
|
||||||
|
// Format:
|
||||||
|
// If the project workdir is a git repo: "2025-08-16-130734.84d33bb" or "2025-08-16-130734.84d33bb.dirty"
|
||||||
|
// If not a git repo: "2025-08-16-130734" which is the current date and time.
|
||||||
|
DefaultTagTemplate = `{{if .Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}}`
|
||||||
|
// DefaultImageTemplate is the default image template used when no image is specified.
|
||||||
|
DefaultImageTemplate = "{{.Project}}/{{.Service}}:" + DefaultTagTemplate
|
||||||
|
)
|
||||||
|
|
||||||
|
// ImageTemplateContext contains structured data for image template processing.
|
||||||
|
type ImageTemplateContext struct {
|
||||||
|
// Project is the project name.
|
||||||
|
Project string
|
||||||
|
// Service is the service name where the image is defined.
|
||||||
|
Service string
|
||||||
|
// Git is the git repo state.
|
||||||
|
Git gitutil.GitState
|
||||||
|
// Tag is the rendered DefaultTagTemplate used for images without a tag.
|
||||||
|
Tag string
|
||||||
|
}
|
||||||
|
|
||||||
|
// newImageTemplateContext creates an ImageTemplateContext for the given project, service, and git state.
|
||||||
|
func newImageTemplateContext(
|
||||||
|
projectName, serviceName string, gitState gitutil.GitState,
|
||||||
|
) (ImageTemplateContext, error) {
|
||||||
|
ctx := ImageTemplateContext{
|
||||||
|
Project: projectName,
|
||||||
|
Service: serviceName,
|
||||||
|
Git: gitState,
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := processImageTemplate(DefaultTagTemplate, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ImageTemplateContext{}, fmt.Errorf("process default tag template: %w", err)
|
||||||
|
}
|
||||||
|
ctx.Tag = tag
|
||||||
|
|
||||||
|
return ctx, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProcessImageTemplates processes image names in services to expand Go template expressions using
|
||||||
|
// git metadata. If a service has no image specified, uses the default template {{.Project}}/{{.Service}}:{{.Tag}}.
|
||||||
|
// If a service has a build section and an image without a tag, appends the default tag template.
|
||||||
|
// Fully specified images are left unchanged.
|
||||||
|
//
|
||||||
|
// Template context: ImageTemplateContext
|
||||||
|
// Template functions: gitsha(length?), gitdate(format, timezone?), date(format, timezone?)
|
||||||
|
//
|
||||||
|
// Examples:
|
||||||
|
//
|
||||||
|
// image: myapp:{{.Tag}} → myapp:2025-08-16-130734.84d33bb
|
||||||
|
// image: registry.io/api:v1.0-{{gitsha 7}} → registry.io/api:v1.0-84d33bb
|
||||||
|
// image: worker:{{gitdate "2006-01-02"}} → worker:2025-08-16
|
||||||
|
// build: . / image: mybackend → mybackend:2025-08-16-130734.84d33bb
|
||||||
|
// build: . / (no image) → myproject/myservice:2025-08-16-130734.84d33bb
|
||||||
|
// image: postgres → postgres (unchanged)
|
||||||
|
func ProcessImageTemplates(project *types.Project) (*types.Project, error) {
|
||||||
|
gitState, err := gitutil.InspectGitState(project.WorkingDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("inspect git state at '%s': %w", project.WorkingDir, err)
|
||||||
|
}
|
||||||
|
ctx, err := newImageTemplateContext(project.Name, "", gitState)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process each service's image.
|
||||||
|
for name, service := range project.Services {
|
||||||
|
ctx.Service = service.Name
|
||||||
|
|
||||||
|
processedImage := ""
|
||||||
|
image := service.Image
|
||||||
|
|
||||||
|
if image == "" {
|
||||||
|
image = DefaultImageTemplate
|
||||||
|
} else {
|
||||||
|
// Check if image contains any template markers.
|
||||||
|
processed, err := processImageTemplate(image, ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("process image template for service '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if processed == image {
|
||||||
|
// Image doesn't contain template markers.
|
||||||
|
// If it doesn't have a tag and has a build section, use the default template for the tag.
|
||||||
|
tagged, err := hasTag(image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !tagged && service.Build != nil {
|
||||||
|
image = image + ":" + DefaultTagTemplate
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Image contains a template which has been processed successfully so use the result.
|
||||||
|
processedImage = processed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if processedImage == "" {
|
||||||
|
if processedImage, err = processImageTemplate(image, ctx); err != nil {
|
||||||
|
return nil, fmt.Errorf("process image template for service '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service.Image = processedImage
|
||||||
|
project.Services[name] = service
|
||||||
|
}
|
||||||
|
|
||||||
|
return project, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// processImageTemplate processes a single image string through the template engine.
|
||||||
|
func processImageTemplate(image string, ctx ImageTemplateContext) (string, error) {
|
||||||
|
funcMap := template.FuncMap{
|
||||||
|
"gitsha": func(length ...int) string {
|
||||||
|
l := 0
|
||||||
|
if len(length) > 0 {
|
||||||
|
l = length[0]
|
||||||
|
}
|
||||||
|
return ctx.Git.ShortSHA(l)
|
||||||
|
},
|
||||||
|
"gitdate": func(layout string, timezone ...string) string {
|
||||||
|
if !ctx.Git.IsRepo || ctx.Git.Date.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
date := ctx.Git.Date
|
||||||
|
if len(timezone) > 0 {
|
||||||
|
if loc, err := time.LoadLocation(timezone[0]); err == nil {
|
||||||
|
date = date.In(loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return date.Format(layout)
|
||||||
|
},
|
||||||
|
"date": func(layout string, timezone ...string) string {
|
||||||
|
t := time.Now().UTC()
|
||||||
|
if len(timezone) > 0 {
|
||||||
|
if loc, err := time.LoadLocation(timezone[0]); err == nil {
|
||||||
|
t = t.In(loc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return t.Format(layout)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse and execute the template.
|
||||||
|
tmpl, err := template.New("image").Funcs(funcMap).Parse(image)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("parse image template '%s': %w", image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err = tmpl.Execute(&buf, ctx); err != nil {
|
||||||
|
return "", fmt.Errorf("execute image template: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buf.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasTag checks if an image name already has an explicit tag or digest.
|
||||||
|
// Returns an error if the image reference is invalid.
|
||||||
|
func hasTag(image string) (bool, error) {
|
||||||
|
ref, err := reference.ParseNormalizedNamed(image)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("parse image reference '%s': %w", image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if it has an explicit tag (e.g., "myapp:v1.0").
|
||||||
|
if _, ok := ref.(reference.Tagged); ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
// Check if it has a digest (e.g., "myapp@sha256:...").
|
||||||
|
if _, ok := ref.(reference.Digested); ok {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/psviderski/uncloud/internal/gitutil"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProcessImageTemplates_Integration(t *testing.T) {
|
||||||
|
// Create a temporary directory for the test git repository.
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
initGitRepo(t, tmpDir)
|
||||||
|
|
||||||
|
// Create an initial commit.
|
||||||
|
testFile := filepath.Join(tmpDir, "test.txt")
|
||||||
|
err := os.WriteFile(testFile, []byte("initial content"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
runGitCommand(t, tmpDir, "add", "test.txt")
|
||||||
|
runGitCommand(t, tmpDir, "commit", "-m", "Initial commit")
|
||||||
|
|
||||||
|
gitState, err := gitutil.InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, gitState.IsRepo)
|
||||||
|
require.False(t, gitState.IsDirty)
|
||||||
|
require.NotEmpty(t, gitState.SHA)
|
||||||
|
|
||||||
|
expectedTag := gitState.Date.UTC().Format("2006-01-02-150405") + "." + gitState.SHA[:7]
|
||||||
|
expectedDate := gitState.Date.UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
t.Run("clean repository", func(t *testing.T) {
|
||||||
|
project := &types.Project{
|
||||||
|
Name: "myproject",
|
||||||
|
WorkingDir: tmpDir,
|
||||||
|
Services: types.Services{
|
||||||
|
"service1": {
|
||||||
|
Name: "service1",
|
||||||
|
Image: "myapp:{{.Tag}}",
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service2": {
|
||||||
|
Name: "service2",
|
||||||
|
Image: `myapp:v1.0-{{gitsha 7}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service3": {
|
||||||
|
Name: "service3",
|
||||||
|
Image: "myapp", // No tag, has build, should get default tag template appended.
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service4": {
|
||||||
|
Name: "service4",
|
||||||
|
Image: "", // Empty image, has build, should use default template.
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service5": {
|
||||||
|
Name: "service5",
|
||||||
|
Image: "{{.Project}}/{{.Service}}:custom", // Custom template.
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service6": {
|
||||||
|
Name: "service6",
|
||||||
|
Image: `registry.example.com/{{.Project}}/{{.Service}}:{{gitdate "2006-01-02"}}-{{gitsha 7}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service7": {
|
||||||
|
Name: "service7",
|
||||||
|
Image: `myapp:{{if .Git.IsRepo}}git-{{.Git.SHA}}{{else}}no-git{{end}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"service8": {
|
||||||
|
Name: "service8",
|
||||||
|
Image: `myapp:{{gitdate "20060102"}}.{{gitsha 10}}{{if .Git.IsDirty}}.dirty{{end}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"postgres": {
|
||||||
|
Name: "postgres",
|
||||||
|
Image: "postgres", // No build section, should NOT get tag appended.
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
Name: "redis",
|
||||||
|
Image: "redis:7.2", // Third-party with tag, should be unchanged.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := ProcessImageTemplates(project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, "myapp:"+expectedTag, p.Services["service1"].Image)
|
||||||
|
assert.Equal(t, "myapp:v1.0-"+gitState.SHA[:7], p.Services["service2"].Image)
|
||||||
|
assert.Equal(t, "myapp:"+expectedTag, p.Services["service3"].Image)
|
||||||
|
assert.Equal(t, "myproject/service4:"+expectedTag, p.Services["service4"].Image)
|
||||||
|
assert.Equal(t, "myproject/service5:custom", p.Services["service5"].Image)
|
||||||
|
expectedService6 := "registry.example.com/myproject/service6:" + expectedDate + "-" + gitState.SHA[:7]
|
||||||
|
assert.Equal(t, expectedService6, p.Services["service6"].Image)
|
||||||
|
assert.Equal(t, "myapp:git-"+gitState.SHA, p.Services["service7"].Image)
|
||||||
|
expectedService8 := "myapp:" + gitState.Date.UTC().Format("20060102") + "." + gitState.SHA[:10]
|
||||||
|
assert.Equal(t, expectedService8, p.Services["service8"].Image)
|
||||||
|
|
||||||
|
// Images without build section should be unchanged.
|
||||||
|
assert.Equal(t, "postgres", p.Services["postgres"].Image, "postgres image should not have tag appended")
|
||||||
|
assert.Equal(t, "redis:7.2", p.Services["redis"].Image, "redis image should be unchanged")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dirty repository", func(t *testing.T) {
|
||||||
|
// Make the repository dirty by creating an uncommitted file.
|
||||||
|
dirtyFile := filepath.Join(tmpDir, "uncommitted.txt")
|
||||||
|
err := os.WriteFile(dirtyFile, []byte("uncommitted changes"), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dirtyGitState, err := gitutil.InspectGitState(tmpDir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, dirtyGitState.IsDirty)
|
||||||
|
require.Equal(t, gitState.SHA, dirtyGitState.SHA)
|
||||||
|
|
||||||
|
project := &types.Project{
|
||||||
|
Name: "myproject",
|
||||||
|
WorkingDir: tmpDir,
|
||||||
|
Services: types.Services{
|
||||||
|
"default": {
|
||||||
|
Name: "default",
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
Name: "app",
|
||||||
|
Image: `myapp:{{gitdate "2006-01-02"}}-{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := ProcessImageTemplates(project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedDirtyDate := dirtyGitState.Date.UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
assert.Equal(t, "myproject/default:"+expectedTag+".dirty", p.Services["default"].Image)
|
||||||
|
expectedApp := "myapp:" + expectedDirtyDate + "-" + dirtyGitState.SHA[:7] + ".dirty"
|
||||||
|
assert.Equal(t, expectedApp, p.Services["app"].Image)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("non-git directory", func(t *testing.T) {
|
||||||
|
nonGitDir := t.TempDir()
|
||||||
|
|
||||||
|
project := &types.Project{
|
||||||
|
Name: "myproject",
|
||||||
|
WorkingDir: nonGitDir,
|
||||||
|
Services: types.Services{
|
||||||
|
"default": {
|
||||||
|
Name: "default",
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
Name: "app",
|
||||||
|
Image: `myapp:{{if .Git.IsRepo}}git{{else}}{{date "2006-01-02"}}{{end}}`,
|
||||||
|
Build: &types.BuildConfig{Context: "."},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
p, err := ProcessImageTemplates(project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Regexp(t, regexp.MustCompile("^myproject/default:"+expectedDate+`-\d{6}$`), p.Services["default"].Image)
|
||||||
|
assert.Equal(t, "myapp:"+expectedDate, p.Services["app"].Image)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessImageTemplate(t *testing.T) {
|
||||||
|
// Fixed time for testing: 2025-08-16 13:07:34 UTC.
|
||||||
|
fixedTime := time.Date(2025, 8, 16, 13, 7, 34, 0, time.UTC)
|
||||||
|
|
||||||
|
gitState := gitutil.GitState{
|
||||||
|
Date: fixedTime,
|
||||||
|
IsDirty: false,
|
||||||
|
IsRepo: true,
|
||||||
|
SHA: "84d33bb1234567890abcdef1234567890abcdef",
|
||||||
|
}
|
||||||
|
ctx, err := newImageTemplateContext("myproject", "myservice", gitState)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
expected string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no template markers",
|
||||||
|
image: "myapp:v1.0",
|
||||||
|
expected: "myapp:v1.0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git SHA function with length",
|
||||||
|
image: "myapp:{{gitsha 10}}",
|
||||||
|
expected: "myapp:84d33bb123",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git SHA function full length",
|
||||||
|
image: "myapp:{{gitsha}}",
|
||||||
|
expected: "myapp:84d33bb1234567890abcdef1234567890abcdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git SHA function negative length",
|
||||||
|
image: "myapp:{{gitsha -1}}",
|
||||||
|
expected: "myapp:84d33bb1234567890abcdef1234567890abcdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git date",
|
||||||
|
image: `myapp:{{gitdate "2006-01-02"}}`,
|
||||||
|
expected: "myapp:2025-08-16",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git date with time",
|
||||||
|
image: `myapp:{{gitdate "2006-01-02-150405"}}`,
|
||||||
|
expected: "myapp:2025-08-16-130734",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git date with timezone",
|
||||||
|
image: `myapp:{{gitdate "2006-01-02-150405" "Australia/Brisbane"}}`,
|
||||||
|
expected: "myapp:2025-08-16-230734",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current date",
|
||||||
|
image: `myapp:{{date "2006-01-02"}}`,
|
||||||
|
expected: `^myapp:\d{4}-\d{2}-\d{2}$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current date with time",
|
||||||
|
image: `myapp:{{date "20060102-150405"}}`,
|
||||||
|
expected: `^myapp:\d{8}-\d{6}$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "current date with timezone",
|
||||||
|
image: `myapp:{{date "20060102-150405" "Local"}}`,
|
||||||
|
expected: `^myapp:\d{8}-\d{6}$`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple template functions",
|
||||||
|
image: `myapp:v1.0-{{gitdate "2006-01-02"}}-{{gitsha 7}}`,
|
||||||
|
expected: "myapp:v1.0-2025-08-16-84d33bb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "project and service variables",
|
||||||
|
image: "{{.Project}}/{{.Service}}:{{gitsha 7}}",
|
||||||
|
expected: "myproject/myservice:84d33bb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "git SHA variable",
|
||||||
|
image: "myapp:{{.Git.SHA}}",
|
||||||
|
expected: "myapp:84d33bb1234567890abcdef1234567890abcdef",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tag variable",
|
||||||
|
image: "myapp:{{.Tag}}",
|
||||||
|
expected: "myapp:2025-08-16-130734.84d33bb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default image template short",
|
||||||
|
image: "{{.Project}}/{{.Service}}:{{.Tag}}",
|
||||||
|
expected: "myproject/myservice:2025-08-16-130734.84d33bb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "default image template long",
|
||||||
|
image: `{{.Project}}/{{.Service}}:{{if .Git.IsRepo}}{{gitdate "2006-01-02-150405"}}.{{gitsha 7}}{{if .Git.IsDirty}}.dirty{{end}}{{else}}{{date "2006-01-02-150405"}}{{end}}`,
|
||||||
|
expected: "myproject/myservice:2025-08-16-130734.84d33bb",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace only",
|
||||||
|
image: " ",
|
||||||
|
expected: " ",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid template syntax",
|
||||||
|
image: "myapp:{{.Git.NonExistent.Field}}",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unclosed template",
|
||||||
|
image: "myapp:{{.Tag",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid gitsha argument - not a number",
|
||||||
|
image: `myapp:{{gitsha "abc"}}`,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid date format - missing quotes",
|
||||||
|
image: "myapp:{{date 2006-01-02}}",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "division by zero in template",
|
||||||
|
image: "myapp:{{divide 1 0}}",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "undefined function",
|
||||||
|
image: "myapp:{{undefined_func}}",
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := processImageTemplate(tt.image, ctx)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
if strings.HasPrefix(tt.expected, "^") {
|
||||||
|
assert.Regexp(t, tt.expected, result)
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasTag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
image string
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "image with tag",
|
||||||
|
image: "myapp:v1.0",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image without tag",
|
||||||
|
image: "myapp",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry with port and tag",
|
||||||
|
image: "localhost:5000/myapp:v1.0",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry with port, no tag",
|
||||||
|
image: "localhost:5000/myapp",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry path with tag",
|
||||||
|
image: "registry.example.com/org/myapp:v1.0",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry path without tag",
|
||||||
|
image: "registry.example.com/org/myapp",
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "image with digest",
|
||||||
|
image: "myapp@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "registry with tag and digest",
|
||||||
|
image: "registry.example.com/myapp:v1.0@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef",
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
// Invalid image references.
|
||||||
|
{
|
||||||
|
name: "image with empty tag",
|
||||||
|
image: "myapp:",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
image: "",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid characters - spaces",
|
||||||
|
image: "my app:v1.0",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid characters - uppercase in name",
|
||||||
|
image: "MyApp:v1.0",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid digest format",
|
||||||
|
image: "myapp@invalid-digest",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "double colon",
|
||||||
|
image: "myapp::tag",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "starts with slash",
|
||||||
|
image: "/myapp:v1.0",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "ends with slash",
|
||||||
|
image: "myapp/:v1.0",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid tag characters",
|
||||||
|
image: "myapp:v1.0@latest",
|
||||||
|
want: false,
|
||||||
|
wantErr: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result, err := hasTag(tt.image)
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
} else {
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, result)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper functions.
|
||||||
|
|
||||||
|
func initGitRepo(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Initialize git repo.
|
||||||
|
runGitCommand(t, dir, "init")
|
||||||
|
runGitCommand(t, dir, "config", "user.email", "test@example.com")
|
||||||
|
runGitCommand(t, dir, "config", "user.name", "Test User")
|
||||||
|
}
|
||||||
|
|
||||||
|
func runGitCommand(t *testing.T, dir string, args ...string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cmd := exec.Command("git", args...)
|
||||||
|
cmd.Dir = dir
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
require.NoError(t, err, "git command failed: %s", output)
|
||||||
|
}
|
||||||
@@ -1,24 +1,22 @@
|
|||||||
package compose
|
package compose
|
||||||
|
|
||||||
// TODO: make compose, cli, and api packages public.
|
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FakeProjectName is a placeholder name for the project to be able to strip it from the resource names used as prefix.
|
|
||||||
const FakeProjectName = "f-a-k-e"
|
|
||||||
|
|
||||||
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
func LoadProject(ctx context.Context, paths []string, opts ...composecli.ProjectOptionsFn) (*types.Project, error) {
|
||||||
defaultOpts := []composecli.ProjectOptionsFn{
|
defaultOpts := []composecli.ProjectOptionsFn{
|
||||||
composecli.WithName(FakeProjectName),
|
|
||||||
// First apply os.Environment, always wins.
|
// First apply os.Environment, always wins.
|
||||||
composecli.WithOsEnv,
|
composecli.WithOsEnv,
|
||||||
// Read dot env file to populate project environment.
|
// Set the local .env file to be loaded by WithDotEnv. COMPOSE_DISABLE_ENV_FILE can disable it.
|
||||||
|
composecli.WithEnvFiles(),
|
||||||
|
// Read environment variables from .env files set by WithEnvFiles (.env by default) to make available
|
||||||
|
// for interpolation.
|
||||||
composecli.WithDotEnv,
|
composecli.WithDotEnv,
|
||||||
// Get compose file path set by COMPOSE_FILE.
|
// Get compose file path set by COMPOSE_FILE.
|
||||||
composecli.WithConfigFileEnv,
|
composecli.WithConfigFileEnv,
|
||||||
@@ -42,6 +40,7 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeProjectPrefixFromNames(project)
|
||||||
if project, err = transformServicesCaddyExtension(project); err != nil {
|
if project, err = transformServicesCaddyExtension(project); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -54,5 +53,19 @@ func LoadProject(ctx context.Context, paths []string, opts ...composecli.Project
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process image templates in services to expand Go template expressions using git repo state.
|
||||||
|
if project, err = ProcessImageTemplates(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// removeProjectPrefixFromNames removes the project name prefix from volume names.
|
||||||
|
func removeProjectPrefixFromNames(project *types.Project) {
|
||||||
|
prefix := project.Name + "_"
|
||||||
|
for name, vol := range project.Volumes {
|
||||||
|
vol.Name = strings.TrimPrefix(vol.Name, prefix)
|
||||||
|
project.Volumes[name] = vol
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package compose
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLoadProject_EnvFileInterpolation tests that environment variables from .env files
|
||||||
|
// are properly loaded and available for interpolation in compose files.
|
||||||
|
func TestLoadProject_EnvFileInterpolation(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
composeYAML string
|
||||||
|
envFile string
|
||||||
|
verify func(t *testing.T, projectDir string)
|
||||||
|
}{
|
||||||
|
// This is a regression test for https://github.com/psviderski/uncloud/issues/144.
|
||||||
|
{
|
||||||
|
name: "simple variable interpolation",
|
||||||
|
envFile: `KEY1=value1
|
||||||
|
KEY2=value2
|
||||||
|
`,
|
||||||
|
composeYAML: `services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
command: ["echo", "${KEY1}", "${KEY2}"]
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
require.Len(t, project.Services, 1)
|
||||||
|
|
||||||
|
webService := project.Services["web"]
|
||||||
|
require.NotNil(t, webService)
|
||||||
|
require.Len(t, webService.Command, 3)
|
||||||
|
assert.Equal(t, "echo", webService.Command[0])
|
||||||
|
assert.Equal(t, "value1", webService.Command[1], "KEY1 should be interpolated from .env file")
|
||||||
|
assert.Equal(t, "value2", webService.Command[2], "KEY2 should be interpolated from .env file")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "environment variable interpolation in service environment",
|
||||||
|
envFile: `DATABASE_URL=postgres://localhost:5432/mydb
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
`,
|
||||||
|
composeYAML: `services:
|
||||||
|
app:
|
||||||
|
image: myapp:latest
|
||||||
|
environment:
|
||||||
|
- DB_CONNECTION=${DATABASE_URL}
|
||||||
|
- CACHE_URL=${REDIS_URL}
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
require.Len(t, project.Services, 1)
|
||||||
|
|
||||||
|
appService := project.Services["app"]
|
||||||
|
require.NotNil(t, appService)
|
||||||
|
require.NotNil(t, appService.Environment)
|
||||||
|
|
||||||
|
assert.Equal(t, "postgres://localhost:5432/mydb", *appService.Environment["DB_CONNECTION"],
|
||||||
|
"DATABASE_URL should be interpolated from .env file")
|
||||||
|
assert.Equal(t, "redis://localhost:6379", *appService.Environment["CACHE_URL"],
|
||||||
|
"REDIS_URL should be interpolated from .env file")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "variable with default value when not in env file",
|
||||||
|
envFile: `EXISTING_VAR=exists
|
||||||
|
`,
|
||||||
|
composeYAML: `services:
|
||||||
|
test:
|
||||||
|
image: busybox
|
||||||
|
environment:
|
||||||
|
- VAR1=${EXISTING_VAR}
|
||||||
|
- VAR2=${MISSING_VAR:-default_value}
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
|
||||||
|
testService := project.Services["test"]
|
||||||
|
require.NotNil(t, testService)
|
||||||
|
|
||||||
|
assert.Equal(t, "exists", *testService.Environment["VAR1"],
|
||||||
|
"EXISTING_VAR should be interpolated from .env file")
|
||||||
|
assert.Equal(t, "default_value", *testService.Environment["VAR2"],
|
||||||
|
"MISSING_VAR should use default value")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "os environment overrides .env file",
|
||||||
|
envFile: `MY_VAR=from_env_file
|
||||||
|
`,
|
||||||
|
composeYAML: `services:
|
||||||
|
override:
|
||||||
|
image: alpine
|
||||||
|
environment:
|
||||||
|
- TEST_VAR=${MY_VAR}
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
t.Setenv("MY_VAR", "from_os_env")
|
||||||
|
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
|
||||||
|
overrideService := project.Services["override"]
|
||||||
|
require.NotNil(t, overrideService)
|
||||||
|
|
||||||
|
// OS environment should win over .env file (WithOsEnv comes before WithDotEnv).
|
||||||
|
assert.Equal(t, "from_os_env", *overrideService.Environment["TEST_VAR"],
|
||||||
|
"OS environment should override .env file")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no .env file - missing variables get empty string",
|
||||||
|
envFile: "",
|
||||||
|
composeYAML: `services:
|
||||||
|
missing:
|
||||||
|
image: ubuntu
|
||||||
|
command: ["echo", "${UNDEFINED_VAR}"]
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
os.Remove(filepath.Join(projectDir, ".env"))
|
||||||
|
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
|
||||||
|
missingService := project.Services["missing"]
|
||||||
|
require.NotNil(t, missingService)
|
||||||
|
require.Len(t, missingService.Command, 2)
|
||||||
|
assert.Equal(t, "", missingService.Command[1], "undefined variable should be empty string")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "COMPOSE_DISABLE_ENV_FILE disables .env loading",
|
||||||
|
envFile: `MY_VAR=from_env_file`,
|
||||||
|
composeYAML: `services:
|
||||||
|
web:
|
||||||
|
image: nginx
|
||||||
|
command: ["echo", "${MY_VAR}"]
|
||||||
|
`,
|
||||||
|
verify: func(t *testing.T, projectDir string) {
|
||||||
|
t.Setenv("COMPOSE_DISABLE_ENV_FILE", "true")
|
||||||
|
|
||||||
|
project, err := LoadProject(context.Background(), []string{filepath.Join(projectDir, "compose.yaml")})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, project)
|
||||||
|
|
||||||
|
webService := project.Services["web"]
|
||||||
|
require.NotNil(t, webService)
|
||||||
|
require.Len(t, webService.Command, 2)
|
||||||
|
|
||||||
|
assert.Equal(t, "", webService.Command[1], "variable should be empty when .env file is disabled")
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
composeFile := filepath.Join(tempDir, "compose.yaml")
|
||||||
|
err := os.WriteFile(composeFile, []byte(tt.composeYAML), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Write .env file if provided.
|
||||||
|
if tt.envFile != "" {
|
||||||
|
envFile := filepath.Join(tempDir, ".env")
|
||||||
|
err = os.WriteFile(envFile, []byte(tt.envFile), 0o644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tt.verify(t, tempDir)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import (
|
|||||||
"maps"
|
"maps"
|
||||||
"os"
|
"os"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/opencontainers/go-digest"
|
"github.com/opencontainers/go-digest"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
@@ -68,7 +68,7 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
}
|
}
|
||||||
|
|
||||||
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
if machines, ok := service.Extensions[MachinesExtensionKey].(MachinesSource); ok {
|
||||||
spec.Placement.Machines = []string(machines)
|
spec.Placement.Machines = machines
|
||||||
}
|
}
|
||||||
|
|
||||||
// Map LogDriver if specified
|
// Map LogDriver if specified
|
||||||
@@ -105,6 +105,15 @@ func ServiceSpecFromCompose(project *types.Project, serviceName string) (api.Ser
|
|||||||
spec.Volumes = volumeSpecs
|
spec.Volumes = volumeSpecs
|
||||||
spec.Container.VolumeMounts = volumeMounts
|
spec.Container.VolumeMounts = volumeMounts
|
||||||
|
|
||||||
|
// Parse configs
|
||||||
|
configSpecs, configMounts, err := configSpecsFromCompose(project.Configs, service.Configs, project.WorkingDir)
|
||||||
|
if err != nil {
|
||||||
|
return spec, err
|
||||||
|
}
|
||||||
|
|
||||||
|
spec.Configs = configSpecs
|
||||||
|
spec.Container.ConfigMounts = configMounts
|
||||||
|
|
||||||
return spec, nil
|
return spec, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,6 +124,10 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
|
|||||||
MemoryReservation: int64(service.MemReservation),
|
MemoryReservation: int64(service.MemReservation),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convert GPU device requests from compose format, appending "gpu" capability.
|
||||||
|
resources.DeviceReservations = append(resources.DeviceReservations,
|
||||||
|
deviceReservationsFromCompose(service.Gpus, "gpu")...)
|
||||||
|
|
||||||
// Map resources from deploy section if specified.
|
// Map resources from deploy section if specified.
|
||||||
if service.Deploy != nil {
|
if service.Deploy != nil {
|
||||||
if service.Deploy.Resources.Limits != nil {
|
if service.Deploy.Resources.Limits != nil {
|
||||||
@@ -130,12 +143,40 @@ func resourcesFromCompose(service types.ServiceConfig) api.ContainerResources {
|
|||||||
if service.Deploy.Resources.Reservations.MemoryBytes > 0 {
|
if service.Deploy.Resources.Reservations.MemoryBytes > 0 {
|
||||||
resources.MemoryReservation = int64(service.Deploy.Resources.Reservations.MemoryBytes)
|
resources.MemoryReservation = int64(service.Deploy.Resources.Reservations.MemoryBytes)
|
||||||
}
|
}
|
||||||
|
// Handle arbitrary device reservations (same structure as Gpus above).
|
||||||
|
resources.DeviceReservations = append(resources.DeviceReservations,
|
||||||
|
deviceReservationsFromCompose(service.Deploy.Resources.Reservations.Devices)...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return resources
|
return resources
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Converts compose-go DeviceRequest format to Docker API DeviceRequest format.
|
||||||
|
// Additional capabilities can be appended via extraCapabilities (e.g., "gpu" for service.Gpus).
|
||||||
|
func deviceReservationsFromCompose(devices []types.DeviceRequest, extraCapabilities ...string) []container.DeviceRequest {
|
||||||
|
if devices == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
requests := make([]container.DeviceRequest, 0, len(devices))
|
||||||
|
for _, deviceRequest := range devices {
|
||||||
|
// Docker expects an OR'd list of AND'd capabilities (e.g. [][]string),
|
||||||
|
// but compose-go provides a single AND'd list (e.g. []string).
|
||||||
|
capabilities := [][]string{append(deviceRequest.Capabilities, extraCapabilities...)}
|
||||||
|
|
||||||
|
spec := container.DeviceRequest{
|
||||||
|
Driver: deviceRequest.Driver,
|
||||||
|
Count: int(deviceRequest.Count),
|
||||||
|
DeviceIDs: deviceRequest.IDs,
|
||||||
|
Capabilities: capabilities,
|
||||||
|
Options: deviceRequest.Options,
|
||||||
|
}
|
||||||
|
requests = append(requests, spec)
|
||||||
|
}
|
||||||
|
return requests
|
||||||
|
}
|
||||||
|
|
||||||
func volumeSpecsFromCompose(
|
func volumeSpecsFromCompose(
|
||||||
volumes types.Volumes, serviceVolumes []types.ServiceVolumeConfig,
|
volumes types.Volumes, serviceVolumes []types.ServiceVolumeConfig,
|
||||||
) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
) ([]api.VolumeSpec, []api.VolumeMount, error) {
|
||||||
@@ -198,7 +239,7 @@ func dockerVolumeSpecFromCompose(serviceVolume types.ServiceVolumeConfig, volume
|
|||||||
Name: serviceVolume.Source,
|
Name: serviceVolume.Source,
|
||||||
Type: api.VolumeTypeVolume,
|
Type: api.VolumeTypeVolume,
|
||||||
VolumeOptions: &api.VolumeOptions{
|
VolumeOptions: &api.VolumeOptions{
|
||||||
Name: strings.TrimPrefix(volume.Name, FakeProjectName+"_"),
|
Name: volume.Name,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/compose-spec/compose-go/v2/loader"
|
"github.com/compose-spec/compose-go/v2/loader"
|
||||||
"github.com/compose-spec/compose-go/v2/types"
|
"github.com/compose-spec/compose-go/v2/types"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/docker/go-units"
|
"github.com/docker/go-units"
|
||||||
"github.com/google/go-cmp/cmp"
|
"github.com/google/go-cmp/cmp"
|
||||||
@@ -19,7 +20,8 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// loadProjectFromContent loads a compose project from YAML content
|
// loadProjectFromContent loads a compose project from YAML content.
|
||||||
|
// Keep the implementation in sync with LoadProject.
|
||||||
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
|
func loadProjectFromContent(t *testing.T, content string) (*types.Project, error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -47,6 +49,7 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removeProjectPrefixFromNames(project)
|
||||||
// Apply extension transformations since we're not using LoadProject.
|
// Apply extension transformations since we're not using LoadProject.
|
||||||
if project, err = transformServicesCaddyExtension(project); err != nil {
|
if project, err = transformServicesCaddyExtension(project); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -60,6 +63,11 @@ func loadProjectFromContent(t *testing.T, content string) (*types.Project, error
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process image templates in services to expand Go template expressions using git repo state.
|
||||||
|
if project, err = ProcessImageTemplates(project); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
return project, nil
|
return project, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,6 +442,193 @@ services:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestServiceSpecFromCompose_GPUs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
composeYAML string
|
||||||
|
expectedDeviceReqs []container.DeviceRequest
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "gpus_all_shorthand",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
gpus: all
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: -1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpus_device_ids",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
gpus:
|
||||||
|
- device_ids: ['0', '1']
|
||||||
|
capabilities: [compute]
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
DeviceIDs: []string{"0", "1"},
|
||||||
|
Capabilities: [][]string{{"compute", "gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpus_count",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
gpus:
|
||||||
|
- count: 2
|
||||||
|
capabilities: [utility]
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"utility", "gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpus_driver_and_options",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
gpus:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [compute, utility]
|
||||||
|
options:
|
||||||
|
key1: value1
|
||||||
|
key2: value2
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"compute", "utility", "gpu"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": "value2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "deploy_resources_reservations_devices",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 1
|
||||||
|
capabilities: [gpu]
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gpus_with_existing_gpu_capability",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
gpus:
|
||||||
|
- capabilities: [gpu, compute]
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
// defaults to "all" when count not specified
|
||||||
|
Count: -1,
|
||||||
|
// gpu appended even if already present (matching Docker Compose behavior)
|
||||||
|
Capabilities: [][]string{{"gpu", "compute", "gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple_device_reservations",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: nvidia/cuda
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- driver: nvidia
|
||||||
|
count: 2
|
||||||
|
capabilities: [gpu, compute]
|
||||||
|
- capabilities: [tpu]
|
||||||
|
count: 1
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "tpu_reservation",
|
||||||
|
composeYAML: `
|
||||||
|
services:
|
||||||
|
ai:
|
||||||
|
image: tensorflow/tensorflow:latest
|
||||||
|
deploy:
|
||||||
|
resources:
|
||||||
|
reservations:
|
||||||
|
devices:
|
||||||
|
- capabilities: [tpu]
|
||||||
|
count: 4
|
||||||
|
driver: google
|
||||||
|
`,
|
||||||
|
expectedDeviceReqs: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "google",
|
||||||
|
Count: 4,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
project, err := loadProjectFromContent(t, tt.composeYAML)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
spec, err := ServiceSpecFromCompose(project, "ai")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expectedDeviceReqs, spec.Container.Resources.DeviceReservations,
|
||||||
|
"DeviceReservations should match expected")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
func TestServiceSpecFromCompose_XMachinesPlacement(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
test config content
|
||||||
+2
@@ -0,0 +1,2 @@
|
|||||||
|
user nginx;
|
||||||
|
worker_processes auto;
|
||||||
@@ -2,6 +2,7 @@ package connector
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -9,6 +10,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine"
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
"github.com/psviderski/uncloud/internal/sshexec"
|
"github.com/psviderski/uncloud/internal/sshexec"
|
||||||
"golang.org/x/crypto/ssh"
|
"golang.org/x/crypto/ssh"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -79,6 +81,15 @@ func (c *SSHConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Dialer returns a proxy dialer for establishing connections within the cluster through the SSH tunnel.
|
||||||
|
// The connector must be created with an existing SSH client or Connect must be called first.
|
||||||
|
func (c *SSHConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
if c.client == nil {
|
||||||
|
return nil, errors.New("SSH connection must be established first")
|
||||||
|
}
|
||||||
|
return c.client, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (c *SSHConnector) Close() error {
|
func (c *SSHConnector) Close() error {
|
||||||
if c.client != nil {
|
if c.client != nil {
|
||||||
err := c.client.Close()
|
err := c.client.Close()
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/docker/cli/cli/connhelper/commandconn"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
|
"google.golang.org/grpc"
|
||||||
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SSHCLIConnector establishes a connection to the machine API by executing SSH CLI
|
||||||
|
// and running `uncloudd dial-stdio` on the remote machine.
|
||||||
|
type SSHCLIConnector struct {
|
||||||
|
config SSHConnectorConfig
|
||||||
|
conn net.Conn
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSSHCLIConnector(cfg *SSHConnectorConfig) *SSHCLIConnector {
|
||||||
|
return &SSHCLIConnector{config: *cfg}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sshCLIDialer implements proxy.ContextDialer by spawning SSH processes with -W flag.
|
||||||
|
type sshCLIDialer struct {
|
||||||
|
config SSHConnectorConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildDialArgs constructs SSH command arguments for -W flag dialing.
|
||||||
|
func (d *sshCLIDialer) buildDialArgs(address string) []string {
|
||||||
|
args := []string{}
|
||||||
|
|
||||||
|
// Add connection timeout to fail fast when node is down.
|
||||||
|
args = append(args, "-o", "ConnectTimeout=5")
|
||||||
|
|
||||||
|
// Add port if non-standard.
|
||||||
|
if d.config.Port != 0 && d.config.Port != 22 {
|
||||||
|
args = append(args, "-p", strconv.Itoa(d.config.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add identity file if specified.
|
||||||
|
if d.config.KeyPath != "" {
|
||||||
|
args = append(args, "-i", d.config.KeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add -W flag for stdin/stdout forwarding to target address.
|
||||||
|
args = append(args, "-W", address)
|
||||||
|
|
||||||
|
// Add user@host.
|
||||||
|
args = append(args, d.config.User+"@"+d.config.Host)
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// DialContext establishes a connection to the target address through an SSH tunnel using -W flag.
|
||||||
|
func (d *sshCLIDialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
|
||||||
|
// Only support TCP connections.
|
||||||
|
if network != "tcp" {
|
||||||
|
return nil, fmt.Errorf("unsupported network type: %s", network)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build SSH command arguments.
|
||||||
|
args := d.buildDialArgs(address)
|
||||||
|
|
||||||
|
// Create connection using commandconn.
|
||||||
|
conn, err := commandconn.New(ctx, "ssh", args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SSH connection to %s@%s for dialing %s: %w", d.config.User, d.config.Host, address, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHCLIConnector) Connect(ctx context.Context) (*grpc.ClientConn, error) {
|
||||||
|
// Build SSH command arguments.
|
||||||
|
args := c.buildSSHArgs()
|
||||||
|
|
||||||
|
// Create connection using commandconn.
|
||||||
|
conn, err := commandconn.New(ctx, "ssh", args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("SSH connection to %s@%s: %w", c.config.User, c.config.Host, err)
|
||||||
|
}
|
||||||
|
c.conn = conn
|
||||||
|
|
||||||
|
// Create gRPC client over the connection.
|
||||||
|
// Use a custom dialer that returns our existing connection.
|
||||||
|
grpcConn, err := grpc.NewClient(
|
||||||
|
"passthrough:///", // Dummy target since we're using a custom dialer.
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
|
||||||
|
return c.conn, nil
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
c.conn.Close()
|
||||||
|
return nil, fmt.Errorf("create machine API client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return grpcConn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSSHArgs constructs the SSH command arguments.
|
||||||
|
func (c *SSHCLIConnector) buildSSHArgs() []string {
|
||||||
|
args := []string{}
|
||||||
|
|
||||||
|
// Add connection timeout to fail fast when node is down.
|
||||||
|
args = append(args, "-o", "ConnectTimeout=5")
|
||||||
|
|
||||||
|
// Add port if non-standard.
|
||||||
|
if c.config.Port != 0 && c.config.Port != 22 {
|
||||||
|
args = append(args, "-p", strconv.Itoa(c.config.Port))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add identity file if specified (backward compatibility with SSHKeyFile).
|
||||||
|
if c.config.KeyPath != "" {
|
||||||
|
args = append(args, "-i", c.config.KeyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add user@host.
|
||||||
|
args = append(args, c.config.User+"@"+c.config.Host)
|
||||||
|
|
||||||
|
// Add remote command: uncloudd dial-stdio
|
||||||
|
args = append(args, "uncloudd", "dial-stdio")
|
||||||
|
|
||||||
|
// Add socket path if non-default.
|
||||||
|
if c.config.SockPath != "" && c.config.SockPath != machine.DefaultUncloudSockPath {
|
||||||
|
args = append(args, "--socket", c.config.SockPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dialer returns a proxy dialer for establishing connections within the cluster through SSH tunnels.
|
||||||
|
func (c *SSHCLIConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
if c.config == (SSHConnectorConfig{}) {
|
||||||
|
return nil, fmt.Errorf("SSH connector not configured")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &sshCLIDialer{
|
||||||
|
config: c.config,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *SSHCLIConnector) Close() error {
|
||||||
|
if c.conn != nil {
|
||||||
|
err := c.conn.Close()
|
||||||
|
c.conn = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package connector
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/machine"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSSHCLIConnector_buildSSHArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config SSHConnectorConfig
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic connection",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with custom port",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 2222,
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "root@example.com", "uncloudd", "dial-stdio"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with identity file",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
KeyPath: "/path/to/key",
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with custom socket path",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
SockPath: "/custom/path/uncloud.sock",
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with default socket path (not included)",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
SockPath: machine.DefaultUncloudSockPath,
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all options combined",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 2222,
|
||||||
|
KeyPath: "/path/to/key",
|
||||||
|
SockPath: "/custom/path/uncloud.sock",
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/path/to/key", "root@example.com", "uncloudd", "dial-stdio", "--socket", "/custom/path/uncloud.sock"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "port 22 not included (default)",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 0,
|
||||||
|
},
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "root@example.com", "uncloudd", "dial-stdio"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
c := &SSHCLIConnector{config: tt.config}
|
||||||
|
got := c.buildSSHArgs()
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSSHCLIDialer_buildDialArgs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config SSHConnectorConfig
|
||||||
|
address string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic connection",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
},
|
||||||
|
address: "10.210.1.1:5000",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom port",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 2222,
|
||||||
|
},
|
||||||
|
address: "10.210.1.1:5000",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with identity file",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 22,
|
||||||
|
KeyPath: "/home/user/.ssh/id_rsa",
|
||||||
|
},
|
||||||
|
address: "10.210.1.1:5000",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom port with identity file",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 2222,
|
||||||
|
KeyPath: "/home/user/.ssh/id_rsa",
|
||||||
|
},
|
||||||
|
address: "10.210.1.1:5000",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-p", "2222", "-i", "/home/user/.ssh/id_rsa", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero port defaults to 22",
|
||||||
|
config: SSHConnectorConfig{
|
||||||
|
User: "root",
|
||||||
|
Host: "example.com",
|
||||||
|
Port: 0,
|
||||||
|
},
|
||||||
|
address: "10.210.1.1:5000",
|
||||||
|
expected: []string{"-o", "ConnectTimeout=5", "-W", "10.210.1.1:5000", "root@example.com"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
d := &sshCLIDialer{config: tt.config}
|
||||||
|
got := d.buildDialArgs(tt.address)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,10 @@ func (c *TCPConnector) Connect(_ context.Context) (*grpc.ClientConn, error) {
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *TCPConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
return nil, fmt.Errorf("proxy connections are not supported over a TCP connection")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *TCPConnector) Close() error {
|
func (c *TCPConnector) Close() error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"github.com/psviderski/uncloud/internal/machine/network"
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
"github.com/psviderski/uncloud/internal/machine/network/tunnel"
|
||||||
"github.com/psviderski/uncloud/pkg/client"
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"golang.org/x/net/proxy"
|
||||||
"google.golang.org/grpc"
|
"google.golang.org/grpc"
|
||||||
"google.golang.org/grpc/credentials/insecure"
|
"google.golang.org/grpc/credentials/insecure"
|
||||||
)
|
)
|
||||||
@@ -75,6 +76,10 @@ func (c *WireGuardConnector) Connect(ctx context.Context) (*grpc.ClientConn, err
|
|||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *WireGuardConnector) Dialer() (proxy.ContextDialer, error) {
|
||||||
|
return nil, fmt.Errorf("proxy connections not implemented for WireGuard connector")
|
||||||
|
}
|
||||||
|
|
||||||
func (c *WireGuardConnector) Close() error {
|
func (c *WireGuardConnector) Close() error {
|
||||||
if c.tun != nil {
|
if c.tun != nil {
|
||||||
c.tun.Close()
|
c.tun.Close()
|
||||||
|
|||||||
+67
-38
@@ -4,17 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
dockercommand "github.com/docker/cli/cli/command"
|
"github.com/containerd/errdefs"
|
||||||
dockerconfig "github.com/docker/cli/cli/config"
|
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/container"
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/registry"
|
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/docker/docker/pkg/jsonmessage"
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
"github.com/psviderski/uncloud/internal/machine/docker"
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
|
machinedocker "github.com/psviderski/uncloud/internal/machine/docker"
|
||||||
"github.com/psviderski/uncloud/internal/secret"
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"google.golang.org/grpc/status"
|
"google.golang.org/grpc/status"
|
||||||
@@ -67,7 +64,7 @@ func (cli *Client) CreateContainer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// NotFound (No such image) error is expected if the image is missing.
|
// NotFound (No such image) error is expected if the image is missing.
|
||||||
if !dockerclient.IsErrNotFound(err) || !strings.Contains(err.Error(), "No such image") {
|
if !errdefs.IsNotFound(err) || !strings.Contains(err.Error(), "No such image") {
|
||||||
return resp, err
|
return resp, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,9 +91,9 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
StatusText: "Pulling",
|
StatusText: "Pulling",
|
||||||
})
|
})
|
||||||
|
|
||||||
opts := docker.PullOptions{}
|
opts := machinedocker.PullOptions{}
|
||||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
||||||
if encodedAuth, err := retrieveRegistryAuthFromDocker(image); err == nil && encodedAuth != "" {
|
if encodedAuth, err := docker.RetrieveLocalDockerRegistryAuth(image); err == nil {
|
||||||
// If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config.
|
// If RegistryAuth is empty, Uncloud daemon will try to retrieve the credentials from its own Docker config.
|
||||||
opts.RegistryAuth = encodedAuth
|
opts.RegistryAuth = encodedAuth
|
||||||
}
|
}
|
||||||
@@ -155,34 +152,6 @@ func (cli *Client) pullImageWithProgress(ctx context.Context, image, machineName
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// retrieveRegistryAuthFromDocker retrieves the authentication token for the specified image from the local Docker
|
|
||||||
// config file. It returns the encoded authentication token if it contains any credentials, or an empty string if
|
|
||||||
// no credentials are found.
|
|
||||||
func retrieveRegistryAuthFromDocker(image string) (string, error) {
|
|
||||||
// Try to retrieve the authentication token for the image from the default local Docker config file.
|
|
||||||
dockerConfig := dockerconfig.LoadDefaultConfigFile(os.Stderr)
|
|
||||||
encodedAuth, err := dockercommand.RetrieveAuthTokenFromImage(dockerConfig, image)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
// The encodedAuth can be a base64-encoded "{}" (empty JSON object) or include a server address but no credentials.
|
|
||||||
// Return encodedAuth only if it contains any credentials.
|
|
||||||
auth, err := registry.DecodeAuthConfig(encodedAuth)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("decode auth config: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if auth.Username == "" &&
|
|
||||||
auth.Password == "" &&
|
|
||||||
auth.Auth == "" &&
|
|
||||||
auth.IdentityToken == "" &&
|
|
||||||
auth.RegistryToken == "" {
|
|
||||||
return "", nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return encodedAuth, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
// toPullProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
// It's based on toPullProgressEvent from Docker Compose.
|
// It's based on toPullProgressEvent from Docker Compose.
|
||||||
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
@@ -230,6 +199,7 @@ func toPullProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// InspectContainer returns the information about the specified container within the service.
|
// InspectContainer returns the information about the specified container within the service.
|
||||||
|
// containerNameOrID can be name, full ID, or ID prefix of the container.
|
||||||
func (cli *Client) InspectContainer(
|
func (cli *Client) InspectContainer(
|
||||||
ctx context.Context, serviceNameOrID, containerNameOrID string,
|
ctx context.Context, serviceNameOrID, containerNameOrID string,
|
||||||
) (api.MachineServiceContainer, error) {
|
) (api.MachineServiceContainer, error) {
|
||||||
@@ -238,10 +208,23 @@ func (cli *Client) InspectContainer(
|
|||||||
return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err)
|
return api.MachineServiceContainer{}, fmt.Errorf("inspect service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
prefixMatchCandidates := []api.MachineServiceContainer{}
|
||||||
for _, c := range svc.Containers {
|
for _, c := range svc.Containers {
|
||||||
if c.Container.ID == containerNameOrID || c.Container.Name == containerNameOrID {
|
if c.Container.ID == containerNameOrID ||
|
||||||
|
c.Container.Name == containerNameOrID {
|
||||||
return c, nil
|
return c, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(c.Container.ID, containerNameOrID) {
|
||||||
|
prefixMatchCandidates = append(prefixMatchCandidates, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(prefixMatchCandidates) == 1 {
|
||||||
|
return prefixMatchCandidates[0], nil
|
||||||
|
} else if len(prefixMatchCandidates) > 1 {
|
||||||
|
return api.MachineServiceContainer{}, fmt.Errorf(
|
||||||
|
"multiple containers found with ID prefix '%s'", containerNameOrID)
|
||||||
}
|
}
|
||||||
|
|
||||||
return api.MachineServiceContainer{}, api.ErrNotFound
|
return api.MachineServiceContainer{}, api.ErrNotFound
|
||||||
@@ -325,3 +308,49 @@ func (cli *Client) RemoveContainer(
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ExecContainer executes a command in a container within the service.
|
||||||
|
// If containerNameOrID is empty, the first container in the service will be used.
|
||||||
|
func (cli *Client) ExecContainer(
|
||||||
|
ctx context.Context, serviceNameOrID, containerNameOrID string, execOpts api.ExecOptions,
|
||||||
|
) (int, error) {
|
||||||
|
var ctr api.MachineServiceContainer
|
||||||
|
|
||||||
|
if containerNameOrID == "" {
|
||||||
|
// Find the first (random) container in the service
|
||||||
|
service, err := cli.InspectService(ctx, serviceNameOrID)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("inspect service: %w", err)
|
||||||
|
}
|
||||||
|
if len(service.Containers) == 0 {
|
||||||
|
return -1, fmt.Errorf("no containers found in service %s", serviceNameOrID)
|
||||||
|
}
|
||||||
|
ctr = service.Containers[0]
|
||||||
|
} else {
|
||||||
|
// Find the specific container
|
||||||
|
var err error
|
||||||
|
ctr, err = cli.InspectContainer(ctx, serviceNameOrID, containerNameOrID)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("inspect container: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
machine, err := cli.InspectMachine(ctx, ctr.MachineID)
|
||||||
|
if err != nil {
|
||||||
|
return -1, fmt.Errorf("inspect machine '%s': %w", ctr.MachineID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Proxy Docker gRPC requests to the machine hosting the container
|
||||||
|
ctx = proxyToMachine(ctx, machine.Machine)
|
||||||
|
|
||||||
|
// Execute the command in the container
|
||||||
|
exitCode, err := cli.Docker.ExecContainer(ctx, machinedocker.ExecConfig{
|
||||||
|
ContainerID: ctr.Container.ID,
|
||||||
|
Options: execOpts,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return exitCode, fmt.Errorf("exec in container %s: %w", ctr.Container.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return exitCode, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,11 +71,29 @@ func EvalContainerSpecChange(current api.ServiceSpec, new api.ServiceSpec) Conta
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compare configs.
|
||||||
|
if len(current.Configs) != len(new.Configs) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
sortConfigs(current.Configs)
|
||||||
|
sortConfigs(new.Configs)
|
||||||
|
for i := range current.Configs {
|
||||||
|
if !current.Configs[i].Equals(new.Configs[i]) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device reservations are immutable, so we'll need to recreate if any have changed
|
||||||
|
if !reflect.DeepEqual(current.Container.Resources.DeviceReservations, newResources.DeviceReservations) {
|
||||||
|
return ContainerNeedsRecreate
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any mutable properties changed.
|
// Check if any mutable properties changed.
|
||||||
if !current.Caddy.Equals(new.Caddy) {
|
if !current.Caddy.Equals(new.Caddy) {
|
||||||
return ContainerNeedsRecreate
|
return ContainerNeedsRecreate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remaining resources are mutable.
|
||||||
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
if !reflect.DeepEqual(current.Container.Resources, newResources) {
|
||||||
return ContainerNeedsUpdate
|
return ContainerNeedsUpdate
|
||||||
}
|
}
|
||||||
@@ -88,3 +106,9 @@ func sortVolumes(volumes []api.VolumeSpec) {
|
|||||||
return volumes[i].Name < volumes[j].Name
|
return volumes[i].Name < volumes[j].Name
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sortConfigs(configs []api.ConfigSpec) {
|
||||||
|
sort.Slice(configs, func(i, j int) bool {
|
||||||
|
return configs[i].Name < configs[j].Name
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package deploy
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
"github.com/docker/docker/api/types/mount"
|
"github.com/docker/docker/api/types/mount"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -1350,6 +1351,398 @@ func TestEvalContainerSpecChange_Volumes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEvalContainerSpecChange_DeviceReservations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
current api.ContainerResources
|
||||||
|
new api.ContainerResources
|
||||||
|
want ContainerSpecStatus
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
current: api.ContainerResources{},
|
||||||
|
new: api.ContainerResources{},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both nil",
|
||||||
|
current: api.ContainerResources{DeviceReservations: nil},
|
||||||
|
new: api.ContainerResources{DeviceReservations: nil},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "both empty",
|
||||||
|
current: api.ContainerResources{DeviceReservations: []container.DeviceRequest{}},
|
||||||
|
new: api.ContainerResources{DeviceReservations: []container.DeviceRequest{}},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set GPU request",
|
||||||
|
current: api.ContainerResources{},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: -1, // all
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unset GPU request",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: -1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "identical GPU request",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "change GPU count",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "change GPU driver",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "amd",
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "change device IDs",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
DeviceIDs: []string{"0"},
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
DeviceIDs: []string{"0", "1"},
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "change capabilities",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "set options",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "change options",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"key": "value1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"key": "value2",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unset options",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "add device request",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "remove device request",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple identical device requests",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu", "compute"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reordered device requests",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"tpu"}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex GPU configuration identical",
|
||||||
|
current: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
DeviceIDs: []string{"0", "1"},
|
||||||
|
Capabilities: [][]string{{"gpu", "compute", "utility"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"runtime": "nvidia",
|
||||||
|
"compute": "exclusive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ContainerResources{
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Driver: "nvidia",
|
||||||
|
Count: 2,
|
||||||
|
DeviceIDs: []string{"0", "1"},
|
||||||
|
Capabilities: [][]string{{"gpu", "compute", "utility"}},
|
||||||
|
Options: map[string]string{
|
||||||
|
"runtime": "nvidia",
|
||||||
|
"compute": "exclusive",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerUpToDate,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
currentSpec := api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "nvidia/cuda:latest",
|
||||||
|
Resources: tt.current,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
newSpec := api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "nvidia/cuda:latest",
|
||||||
|
Resources: tt.new,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := EvalContainerSpecChange(currentSpec, newSpec)
|
||||||
|
assert.Equal(t, tt.want, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEvalContainerSpecChange_Mixed(t *testing.T) {
|
func TestEvalContainerSpecChange_Mixed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -1399,6 +1792,32 @@ func TestEvalContainerSpecChange_Mixed(t *testing.T) {
|
|||||||
},
|
},
|
||||||
want: ContainerNeedsRecreate,
|
want: ContainerNeedsRecreate,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "mutable memory change with immutable device reservation change",
|
||||||
|
current: api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "nvidia/cuda:latest",
|
||||||
|
Resources: api.ContainerResources{
|
||||||
|
Memory: 100 * 1024 * 1024,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
new: api.ServiceSpec{
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "nvidia/cuda:latest",
|
||||||
|
Resources: api.ContainerResources{
|
||||||
|
Memory: 200 * 1024 * 1024,
|
||||||
|
DeviceReservations: []container.DeviceRequest{
|
||||||
|
{
|
||||||
|
Count: 1,
|
||||||
|
Capabilities: [][]string{{"gpu"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: ContainerNeedsRecreate,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
|
|||||||
@@ -29,6 +29,8 @@ type NameResolver interface {
|
|||||||
ContainerName(containerID string) string
|
ContainerName(containerID string) string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: pass api.ServiceContainer to operations to simplify operation formatting in the plan.
|
||||||
|
|
||||||
// RunContainerOperation creates and starts a new container on a specific machine.
|
// RunContainerOperation creates and starts a new container on a specific machine.
|
||||||
type RunContainerOperation struct {
|
type RunContainerOperation struct {
|
||||||
ServiceID string
|
ServiceID string
|
||||||
@@ -56,8 +58,8 @@ func (o *RunContainerOperation) Format(resolver NameResolver) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *RunContainerOperation) String() string {
|
func (o *RunContainerOperation) String() string {
|
||||||
return fmt.Sprintf("RunContainerOperation[service_id=%s, image=%s, machine_id=%s]",
|
return fmt.Sprintf("RunContainerOperation[machine_id=%s service_id=%s image=%s]",
|
||||||
o.ServiceID, o.Spec.Container.Image, o.MachineID)
|
o.MachineID, o.ServiceID, o.Spec.Container.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopContainerOperation stops a container on a specific machine.
|
// StopContainerOperation stops a container on a specific machine.
|
||||||
@@ -76,26 +78,26 @@ func (o *StopContainerOperation) Execute(ctx context.Context, cli Client) error
|
|||||||
|
|
||||||
func (o *StopContainerOperation) Format(resolver NameResolver) string {
|
func (o *StopContainerOperation) Format(resolver NameResolver) string {
|
||||||
machineName := resolver.MachineName(o.MachineID)
|
machineName := resolver.MachineName(o.MachineID)
|
||||||
return fmt.Sprintf("%s: Stop container [name=%s]", machineName, resolver.ContainerName(o.ContainerID))
|
return fmt.Sprintf("%s: Stop container [id=%s name=%s]", machineName,
|
||||||
|
o.ContainerID[:12], resolver.ContainerName(o.ContainerID))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *StopContainerOperation) String() string {
|
func (o *StopContainerOperation) String() string {
|
||||||
return fmt.Sprintf("StopContainerOperation[service_id=%s, container_id=%s, machine_id=%s]",
|
return fmt.Sprintf("StopContainerOperation[machine_id=%s service_id=%s container_id=%s]",
|
||||||
o.ServiceID, o.ContainerID, o.MachineID)
|
o.MachineID, o.ServiceID, o.ContainerID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemoveContainerOperation stops and removes a container from a specific machine.
|
// RemoveContainerOperation stops and removes a container from a specific machine.
|
||||||
type RemoveContainerOperation struct {
|
type RemoveContainerOperation struct {
|
||||||
ServiceID string
|
MachineID string
|
||||||
ContainerID string
|
Container api.ServiceContainer
|
||||||
MachineID string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
|
func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) error {
|
||||||
if err := cli.StopContainer(ctx, o.ServiceID, o.ContainerID, container.StopOptions{}); err != nil {
|
if err := cli.StopContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.StopOptions{}); err != nil {
|
||||||
return fmt.Errorf("stop container: %w", err)
|
return fmt.Errorf("stop container: %w", err)
|
||||||
}
|
}
|
||||||
if err := cli.RemoveContainer(ctx, o.ServiceID, o.ContainerID, container.RemoveOptions{
|
if err := cli.RemoveContainer(ctx, o.Container.ServiceID(), o.Container.ID, container.RemoveOptions{
|
||||||
// Remove anonymous volumes created by the container.
|
// Remove anonymous volumes created by the container.
|
||||||
RemoveVolumes: true,
|
RemoveVolumes: true,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
@@ -107,12 +109,13 @@ func (o *RemoveContainerOperation) Execute(ctx context.Context, cli Client) erro
|
|||||||
|
|
||||||
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
|
func (o *RemoveContainerOperation) Format(resolver NameResolver) string {
|
||||||
machineName := resolver.MachineName(o.MachineID)
|
machineName := resolver.MachineName(o.MachineID)
|
||||||
return fmt.Sprintf("%s: Remove container [name=%s]", machineName, resolver.ContainerName(o.ContainerID))
|
return fmt.Sprintf("%s: Remove container [id=%s image=%s]",
|
||||||
|
machineName, o.Container.ShortID(), o.Container.Config.Image)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *RemoveContainerOperation) String() string {
|
func (o *RemoveContainerOperation) String() string {
|
||||||
return fmt.Sprintf("RemoveContainerOperation[service_id=%s, container_id=%s, machine_id=%s]",
|
return fmt.Sprintf("RemoveContainerOperation[machine_id=%s service_id=%s container_id=%s]",
|
||||||
o.ServiceID, o.ContainerID, o.MachineID)
|
o.MachineID, o.Container.ServiceID(), o.Container.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateVolumeOperation creates a volume on a specific machine.
|
// CreateVolumeOperation creates a volume on a specific machine.
|
||||||
@@ -151,8 +154,8 @@ func (o *CreateVolumeOperation) Format(_ NameResolver) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (o *CreateVolumeOperation) String() string {
|
func (o *CreateVolumeOperation) String() string {
|
||||||
return fmt.Sprintf("CreateVolumeOperation[volume=%s, machine_id=%s]",
|
return fmt.Sprintf("CreateVolumeOperation[machine_id=%s volume=%s]",
|
||||||
o.VolumeSpec.DockerVolumeName(), o.MachineID)
|
o.MachineID, o.VolumeSpec.DockerVolumeName())
|
||||||
}
|
}
|
||||||
|
|
||||||
// SequenceOperation is a composite operation that executes a sequence of operations in order.
|
// SequenceOperation is a composite operation that executes a sequence of operations in order.
|
||||||
|
|||||||
@@ -182,9 +182,8 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
|||||||
|
|
||||||
// Remove the old container.
|
// Remove the old container.
|
||||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||||
ServiceID: plan.ServiceID,
|
MachineID: m.Id,
|
||||||
ContainerID: ctr.ID,
|
Container: ctr,
|
||||||
MachineID: m.Id,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,9 +191,8 @@ func (s *RollingStrategy) planReplicated(svc *api.Service, spec api.ServiceSpec)
|
|||||||
for mid, containers := range containersOnMachine {
|
for mid, containers := range containersOnMachine {
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||||
ServiceID: plan.ServiceID,
|
MachineID: mid,
|
||||||
ContainerID: c.ID,
|
Container: c,
|
||||||
MachineID: mid,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -244,9 +242,8 @@ func (s *RollingStrategy) planGlobal(svc *api.Service, spec api.ServiceSpec) (Pl
|
|||||||
for _, containers := range containersOnMachine {
|
for _, containers := range containersOnMachine {
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
plan.Operations = append(plan.Operations, &RemoveContainerOperation{
|
||||||
ServiceID: plan.ServiceID,
|
MachineID: c.MachineID,
|
||||||
ContainerID: c.Container.ID,
|
Container: c.Container,
|
||||||
MachineID: c.MachineID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -295,9 +292,8 @@ func reconcileGlobalContainer(
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
ops = append(ops, &RemoveContainerOperation{
|
ops = append(ops, &RemoveContainerOperation{
|
||||||
ServiceID: serviceID,
|
MachineID: old.MachineID,
|
||||||
ContainerID: old.Container.ID,
|
Container: old.Container,
|
||||||
MachineID: old.MachineID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
break
|
break
|
||||||
@@ -338,9 +334,8 @@ func reconcileGlobalContainer(
|
|||||||
// Remove the old containers.
|
// Remove the old containers.
|
||||||
for _, c := range containers {
|
for _, c := range containers {
|
||||||
ops = append(ops, &RemoveContainerOperation{
|
ops = append(ops, &RemoveContainerOperation{
|
||||||
ServiceID: serviceID,
|
MachineID: c.MachineID,
|
||||||
ContainerID: c.Container.ID,
|
Container: c.Container,
|
||||||
MachineID: c.MachineID,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+477
-1
@@ -2,14 +2,38 @@ package client
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
"github.com/docker/docker/api/types/filters"
|
||||||
|
"github.com/docker/docker/api/types/image"
|
||||||
dockerclient "github.com/docker/docker/client"
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/docker/docker/pkg/jsonmessage"
|
||||||
|
"github.com/docker/go-connections/nat"
|
||||||
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||||
|
"github.com/psviderski/uncloud/internal/docker"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/constants"
|
||||||
|
"github.com/psviderski/uncloud/internal/machine/network"
|
||||||
|
"github.com/psviderski/uncloud/internal/proxy"
|
||||||
|
"github.com/psviderski/uncloud/internal/secret"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
netproxy "golang.org/x/net/proxy"
|
||||||
)
|
)
|
||||||
|
|
||||||
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineImage, error) {
|
||||||
images, err := cli.Docker.InspectImage(ctx, id)
|
images, err := cli.Docker.InspectImage(ctx, id)
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
err = api.ErrNotFound
|
err = api.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,3 +43,455 @@ func (cli *Client) InspectImage(ctx context.Context, id string) ([]api.MachineIm
|
|||||||
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
func (cli *Client) InspectRemoteImage(ctx context.Context, id string) ([]api.MachineRemoteImage, error) {
|
||||||
return cli.Docker.InspectRemoteImage(ctx, id)
|
return cli.Docker.InspectRemoteImage(ctx, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListImages returns a list of images on specified machines in the cluster. If no machines are specified in the filter,
|
||||||
|
// it lists images on all machines.
|
||||||
|
func (cli *Client) ListImages(ctx context.Context, filter api.ImageFilter) ([]api.MachineImages, error) {
|
||||||
|
// Broadcast the image list request to the specified machines or all machines if none specified.
|
||||||
|
listCtx, machines, err := api.ProxyMachinesContext(ctx, cli, filter.Machines)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request context to broadcast to machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := image.ListOptions{Manifests: true}
|
||||||
|
if filter.Name != "" {
|
||||||
|
opts.Filters = filters.NewArgs(
|
||||||
|
filters.Arg("reference", filter.Name),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
optsBytes, err := json.Marshal(opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := cli.Docker.GRPCClient.ListImages(listCtx, &pb.ListImagesRequest{Options: optsBytes})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
machineImages := make([]api.MachineImages, len(resp.Messages))
|
||||||
|
for i, msg := range resp.Messages {
|
||||||
|
machineImages[i].Metadata = msg.Metadata
|
||||||
|
// TODO: handle this in the grpc-proxy router and always provide Metadata if possible.
|
||||||
|
if msg.Metadata == nil {
|
||||||
|
// Metadata can be nil if the request was broadcasted to only one machine.
|
||||||
|
machineImages[i].Metadata = &pb.Metadata{
|
||||||
|
Machine: machines[0].Machine.Id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Replace management IP with machine ID for friendlier error messages.
|
||||||
|
// TODO: migrate Metadata.Machine to use machine ID instead of IP in the grpc-proxy router.
|
||||||
|
if m := machines.FindByManagementIP(msg.Metadata.Machine); m != nil {
|
||||||
|
machineImages[i].Metadata.Machine = m.Machine.Id
|
||||||
|
}
|
||||||
|
if msg.Metadata.Error != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(msg.Images) > 0 {
|
||||||
|
if err = json.Unmarshal(msg.Images, &machineImages[i].Images); err != nil {
|
||||||
|
return nil, fmt.Errorf("unmarshal images: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
machineImages[i].ContainerdStore = msg.ContainerdStore
|
||||||
|
}
|
||||||
|
|
||||||
|
return machineImages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type PushImageOptions struct {
|
||||||
|
// AllMachines pushes the image to all machines in the cluster. Takes precedence over Machines field.
|
||||||
|
AllMachines bool
|
||||||
|
// Machines is a list of machine names or IDs to push the image to. If empty and AllMachines is false,
|
||||||
|
// pushes to the machine the client is connected to.
|
||||||
|
Machines []string
|
||||||
|
// Platform to push for a multi-platform image. Local Docker must use containerd image store
|
||||||
|
// to support multi-platform images.
|
||||||
|
Platform *ocispec.Platform
|
||||||
|
}
|
||||||
|
|
||||||
|
// PushImage pushes a local Docker image to the specified machines. If no machines are specified,
|
||||||
|
// it pushes to the machine the client is connected to.
|
||||||
|
func (cli *Client) PushImage(ctx context.Context, image string, opts PushImageOptions) error {
|
||||||
|
dockerCliWrapped, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv,
|
||||||
|
dockerclient.WithAPIVersionNegotiation())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create Docker client: %w", err)
|
||||||
|
}
|
||||||
|
dockerCli := &docker.Client{Client: dockerCliWrapped}
|
||||||
|
defer dockerCli.Close()
|
||||||
|
|
||||||
|
// Check if Docker image exists locally.
|
||||||
|
if _, err = dockerCli.ImageInspect(ctx, image); err != nil {
|
||||||
|
if errdefs.IsNotFound(err) {
|
||||||
|
return fmt.Errorf("image '%s' not found locally", image)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("inspect image '%s' locally: %w", image, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the machine info for the specified machines or the connected machine if none are specified.
|
||||||
|
var machines []*pb.MachineInfo
|
||||||
|
if opts.AllMachines {
|
||||||
|
machineMembers, err := cli.ListMachines(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
for _, mm := range machineMembers {
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
} else if len(opts.Machines) > 0 {
|
||||||
|
machineMembers, err := cli.ListMachines(ctx, &api.MachineFilter{
|
||||||
|
NamesOrIDs: opts.Machines,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("list machines: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if all specified machines were found.
|
||||||
|
if len(machineMembers) != len(opts.Machines) {
|
||||||
|
var notFound []string
|
||||||
|
for _, nameOrID := range opts.Machines {
|
||||||
|
if machineMembers.FindByNameOrID(nameOrID) == nil {
|
||||||
|
notFound = append(notFound, nameOrID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Errorf("machines not found: %s", strings.Join(notFound, ", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mm := range machineMembers {
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No machines specified, use the connected machine.
|
||||||
|
m, err := cli.MachineClient.Inspect(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect connected machine: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the machine has been renamed, the new name will only be stored in the cluster store. .Inspect will return
|
||||||
|
// the old name from the machine config. So we need to fetch the machine info from the cluster.
|
||||||
|
// TODO: make one source of truth for machine info.
|
||||||
|
mm, err := cli.InspectMachine(ctx, m.Id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("inspect machine: %w", err)
|
||||||
|
}
|
||||||
|
machines = append(machines, mm.Machine)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push image to all specified machines.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
errCh := make(chan error, len(machines))
|
||||||
|
|
||||||
|
// TODO: detect the target machine platform and figure out how to handle scenarios when local and target
|
||||||
|
// platforms differ.
|
||||||
|
for _, m := range machines {
|
||||||
|
wg.Go(func() {
|
||||||
|
if err := cli.pushImageToMachine(ctx, dockerCli, image, m, opts.Platform); err != nil {
|
||||||
|
errCh <- fmt.Errorf("push image to machine '%s': %w", m.Name, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
close(errCh)
|
||||||
|
|
||||||
|
var errs []error
|
||||||
|
for err = range errCh {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pushImageToMachine pushes a local Docker image to a specific machine using local port forwarding to its unregistry.
|
||||||
|
func (cli *Client) pushImageToMachine(
|
||||||
|
ctx context.Context,
|
||||||
|
dockerCli *docker.Client,
|
||||||
|
imageName string,
|
||||||
|
machine *pb.MachineInfo,
|
||||||
|
platform *ocispec.Platform,
|
||||||
|
) error {
|
||||||
|
pw := progress.ContextWriter(ctx)
|
||||||
|
boldStyle := lipgloss.NewStyle().Bold(true)
|
||||||
|
pushEventID := fmt.Sprintf("Pushing %s to %s", boldStyle.Render(imageName), boldStyle.Render(machine.Name))
|
||||||
|
|
||||||
|
// Check the Docker image store type on the target machine.
|
||||||
|
images, err := cli.ListImages(ctx, api.ImageFilter{
|
||||||
|
Machines: []string{machine.Id},
|
||||||
|
Name: "%invalid-name-to-only-check-store-type%",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("check Docker image store type on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only support Docker with containerd image store enabled to avoid the confusion of pushing images to containerd
|
||||||
|
// and then not being able to see and use them in Docker.
|
||||||
|
if !images[0].ContainerdStore {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, "containerd image store required"))
|
||||||
|
return fmt.Errorf("docker on machine '%s' is not using containerd image store, "+
|
||||||
|
"which is required for pushing images. Follow the instructions to enable it: "+
|
||||||
|
"https://docs.docker.com/engine/storage/containerd/, and then restart the uncloud daemon "+
|
||||||
|
"via 'systemctl restart uncloud'", machine.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
machineSubnet, _ := machine.Network.Subnet.ToPrefix()
|
||||||
|
machineIP := network.MachineIP(machineSubnet)
|
||||||
|
unregistryAddr := net.JoinHostPort(machineIP.String(), strconv.Itoa(constants.UnregistryPort))
|
||||||
|
|
||||||
|
dialer, err := cli.connector.Dialer()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get proxy dialer: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxyEventID := fmt.Sprintf("Proxy to unregistry on %s", boldStyle.Render(machine.Name))
|
||||||
|
pw.Event(progress.StartingEvent(proxyEventID))
|
||||||
|
|
||||||
|
// Forward local port 127.0.0.1:PORT to the machine's unregistry over the established client connection.
|
||||||
|
unregProxy, err := newUnregistryProxy(ctx, unregistryAddr, dialer, func(err error) {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("create local proxy to unregistry on machine '%s': %w", machine.Name, err)
|
||||||
|
}
|
||||||
|
// Get the local port the unregistry proxy is listening on.
|
||||||
|
proxyPort := unregProxy.Listener.Addr().(*net.TCPAddr).Port
|
||||||
|
|
||||||
|
proxyCtx, cancelProxy := context.WithCancel(ctx)
|
||||||
|
proxyCtrID := ""
|
||||||
|
pushImageTag := ""
|
||||||
|
|
||||||
|
// Cleanup function to remove temporary resources and stop proxies.
|
||||||
|
cleanup := func() {
|
||||||
|
// Remove temporary image tag.
|
||||||
|
if pushImageTag != "" {
|
||||||
|
dockerCli.ImageRemove(ctx, pushImageTag, image.RemoveOptions{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove socat proxy container.
|
||||||
|
if proxyCtrID != "" {
|
||||||
|
dockerCli.ContainerRemove(ctx, proxyCtrID, container.RemoveOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelProxy()
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
go unregProxy.Run(proxyCtx)
|
||||||
|
|
||||||
|
dockerVirtualised, err := isDockerVirtualised(ctx, dockerCli)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dockerVirtualised {
|
||||||
|
// Run socat proxy container to forward a localhost port from within the Docker VM to the host machine.
|
||||||
|
pw.Event(progress.Event{
|
||||||
|
ID: proxyEventID,
|
||||||
|
Status: progress.Working,
|
||||||
|
StatusText: "Starting",
|
||||||
|
Text: "(detected Docker in VM locally, starting socat proxy container)",
|
||||||
|
})
|
||||||
|
|
||||||
|
proxyCtrID, proxyPort, err = runDockerVMProxyContainer(ctx, dockerCli, proxyPort)
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(proxyEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("run socat container to proxy unregistry to Docker VM: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pw.Event(progress.Event{
|
||||||
|
ID: proxyEventID,
|
||||||
|
Status: progress.Done,
|
||||||
|
StatusText: "Started",
|
||||||
|
Text: fmt.Sprintf("(localhost:%d → %s)", proxyPort, unregistryAddr),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Tag the image for pushing through the proxy.
|
||||||
|
pushImageTag = fmt.Sprintf("127.0.0.1:%d/%s", proxyPort, imageName)
|
||||||
|
if err = dockerCli.ImageTag(ctx, imageName, pushImageTag); err != nil {
|
||||||
|
return fmt.Errorf("tag image for push: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the image through the proxy.
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Working, "Pushing"))
|
||||||
|
|
||||||
|
pushCh, err := dockerCli.PushImage(ctx, pushImageTag, image.PushOptions{
|
||||||
|
Platform: platform,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, err.Error()))
|
||||||
|
return fmt.Errorf("push image: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for push to complete by reading all progress messages and converting them to events.
|
||||||
|
// If the context is cancelled, the pushCh will receive a context cancellation error.
|
||||||
|
for msg := range pushCh {
|
||||||
|
if msg.Err != nil {
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Error, msg.Err.Error()))
|
||||||
|
return fmt.Errorf("push image: %w", msg.Err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: support quite mode like in compose: --quiet Push without printing progress information
|
||||||
|
if e := toPushProgressEvent(msg.Message); e != nil {
|
||||||
|
e.ID = fmt.Sprintf("Layer %s on %s:", e.ID, boldStyle.Render(machine.Name))
|
||||||
|
e.ParentID = pushEventID
|
||||||
|
pw.Event(*e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pw.Event(progress.NewEvent(pushEventID, progress.Done, "Pushed"))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newUnregistryProxy(
|
||||||
|
ctx context.Context, remoteAddr string, dialer netproxy.ContextDialer, onError func(error),
|
||||||
|
) (*proxy.Proxy, error) {
|
||||||
|
// Test remote connectivity before creating a proxy.
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
testConn, err := dialer.DialContext(ctx, "tcp", remoteAddr)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("connect to remote address '%s': %w", remoteAddr, err)
|
||||||
|
}
|
||||||
|
testConn.Close()
|
||||||
|
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("listen on an available port on 127.0.0.1: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &proxy.Proxy{
|
||||||
|
Listener: listener,
|
||||||
|
RemoteAddr: remoteAddr,
|
||||||
|
DialContext: dialer.DialContext,
|
||||||
|
OnError: onError,
|
||||||
|
}
|
||||||
|
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isDockerVirtualised checks if Docker is running in a virtualised environment like Docker Desktop on macOS.
|
||||||
|
func isDockerVirtualised(ctx context.Context, dockerCli *docker.Client) (bool, error) {
|
||||||
|
info, err := dockerCli.Info(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("get Docker info: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualisedHostnames := []string{"docker-desktop", "colima"}
|
||||||
|
for _, name := range virtualisedHostnames {
|
||||||
|
if strings.Contains(strings.ToLower(info.Name), name) {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// runDockerVMProxyContainer creates a socat container to proxy an available localhost port within the Docker VM
|
||||||
|
// (e.g. Docker Desktop on macOS) to the specified target port on the host machine.
|
||||||
|
// Returns the container ID and the localhost port the container port is bound to.
|
||||||
|
// TODO: accept custom image name.
|
||||||
|
func runDockerVMProxyContainer(ctx context.Context, dockerCli *docker.Client, targetPort int) (string, int, error) {
|
||||||
|
suffix, err := secret.RandomAlphaNumeric(4)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("generate random suffix: %w", err)
|
||||||
|
}
|
||||||
|
containerName := fmt.Sprintf("uncloud-push-proxy-%s", suffix)
|
||||||
|
|
||||||
|
containerPort := nat.Port("5000/tcp")
|
||||||
|
config := &container.Config{
|
||||||
|
// TODO: make image configurable.
|
||||||
|
Image: "alpine/socat:latest",
|
||||||
|
// Reset the default entrypoint "socat".
|
||||||
|
Entrypoint: []string{},
|
||||||
|
Cmd: []string{
|
||||||
|
"timeout", "1800", // Auto-terminate socat after 30 minutes.
|
||||||
|
"socat",
|
||||||
|
"TCP-LISTEN:5000,fork,reuseaddr",
|
||||||
|
fmt.Sprintf("TCP-CONNECT:host.docker.internal:%d", targetPort),
|
||||||
|
},
|
||||||
|
ExposedPorts: nat.PortSet{
|
||||||
|
containerPort: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get an available port on localhost to bind the container port to by creating a temporary listener and closing it.
|
||||||
|
// We need to explicitly specify the host port and not rely on Docker mapping because if not specified,
|
||||||
|
// 'docker push' from Docker Desktop is unable to reach the randomly mapped one for some reason.
|
||||||
|
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("reserve a local port: %w", err)
|
||||||
|
}
|
||||||
|
hostPort := listener.Addr().(*net.TCPAddr).Port
|
||||||
|
listener.Close()
|
||||||
|
|
||||||
|
hostConfig := &container.HostConfig{
|
||||||
|
AutoRemove: true,
|
||||||
|
PortBindings: nat.PortMap{
|
||||||
|
containerPort: []nat.PortBinding{
|
||||||
|
{
|
||||||
|
HostIP: "127.0.0.1",
|
||||||
|
HostPort: strconv.Itoa(hostPort),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := dockerCli.CreateContainerWithImagePull(ctx, containerName, config, hostConfig)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, fmt.Errorf("create socat proxy container: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup := func() {
|
||||||
|
dockerCli.ContainerRemove(ctx, resp.ID, container.RemoveOptions{Force: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = dockerCli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil {
|
||||||
|
// Clean up if start fails.
|
||||||
|
cleanup()
|
||||||
|
return "", 0, fmt.Errorf("start socat proxy container %s: %w", resp.ID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp.ID, hostPort, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// toPushProgressEvent converts a JSON progress message from the Docker API to a progress event.
|
||||||
|
// It's based on toPushProgressEvent from Docker Compose.
|
||||||
|
func toPushProgressEvent(jm jsonmessage.JSONMessage) *progress.Event {
|
||||||
|
if jm.ID == "" || jm.Progress == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
status := progress.Working
|
||||||
|
percent := 0
|
||||||
|
|
||||||
|
if jm.Progress.Total > 0 {
|
||||||
|
percent = int(jm.Progress.Current * 100 / jm.Progress.Total)
|
||||||
|
// Cap percent at 100 to prevent index out of bounds in progress display.
|
||||||
|
// Docker can report Current > Total in some cases (e.g., compression).
|
||||||
|
if percent > 100 {
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch jm.Status {
|
||||||
|
case "Pushed", "Layer already exists":
|
||||||
|
status = progress.Done
|
||||||
|
percent = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
return &progress.Event{
|
||||||
|
ID: jm.ID,
|
||||||
|
Current: jm.Progress.Current,
|
||||||
|
Total: jm.Progress.Total,
|
||||||
|
Percent: percent,
|
||||||
|
Text: jm.Status,
|
||||||
|
Status: status,
|
||||||
|
StatusText: jm.Progress.String(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ func (cli *Client) InspectService(ctx context.Context, nameOrID string) (api.Ser
|
|||||||
|
|
||||||
machineID := ""
|
machineID := ""
|
||||||
if mc.Metadata == nil {
|
if mc.Metadata == nil {
|
||||||
// ListContainers was proxied to only one machine.
|
// ListServiceContainers was proxied to only one machine.
|
||||||
for _, v := range machineIDByManagementIP {
|
for _, v := range machineIDByManagementIP {
|
||||||
machineID = v
|
machineID = v
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/containerd/errdefs"
|
||||||
"github.com/docker/compose/v2/pkg/progress"
|
"github.com/docker/compose/v2/pkg/progress"
|
||||||
"github.com/docker/docker/api/types/volume"
|
"github.com/docker/docker/api/types/volume"
|
||||||
dockerclient "github.com/docker/docker/client"
|
|
||||||
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
"github.com/psviderski/uncloud/internal/machine/api/pb"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
)
|
)
|
||||||
@@ -123,7 +123,7 @@ func (cli *Client) RemoveVolume(ctx context.Context, machineNameOrID, volumeName
|
|||||||
pw.Event(progress.RemovingEvent(eventID))
|
pw.Event(progress.RemovingEvent(eventID))
|
||||||
|
|
||||||
if err = cli.Docker.RemoveVolume(ctx, volumeName, force); err != nil {
|
if err = cli.Docker.RemoveVolume(ctx, volumeName, force); err != nil {
|
||||||
if dockerclient.IsErrNotFound(err) {
|
if errdefs.IsNotFound(err) {
|
||||||
return api.ErrNotFound
|
return api.ErrNotFound
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
|
|||||||
+70
-2
@@ -15,12 +15,27 @@ UNCLOUD_GROUP_ADD_USER=${UNCLOUD_GROUP_ADD_USER:-}
|
|||||||
UNCLOUD_DATA_DIR=${UNCLOUD_DATA_DIR:-/var/lib/uncloud}
|
UNCLOUD_DATA_DIR=${UNCLOUD_DATA_DIR:-/var/lib/uncloud}
|
||||||
|
|
||||||
CORROSION_GITHUB_URL="https://github.com/psviderski/corrosion"
|
CORROSION_GITHUB_URL="https://github.com/psviderski/corrosion"
|
||||||
CORROSION_VERSION=${CORROSION_VERSION:-latest}
|
CORROSION_VERSION=${CORROSION_VERSION:-v0.2.2}
|
||||||
|
|
||||||
|
DOCKER_ALREADY_INSTALLED=false
|
||||||
|
CONTAINERD_IMAGE_STORE_ENABLED=false
|
||||||
|
DOCKER_DAEMON_CONFIG_FILE=${DOCKER_DAEMON_CONFIG_FILE:-/etc/docker/daemon.json}
|
||||||
|
# Docker daemon configuration optimised for Uncloud.
|
||||||
|
DOCKER_DAEMON_CONFIG='{
|
||||||
|
"features": {
|
||||||
|
"containerd-snapshotter": true
|
||||||
|
},
|
||||||
|
"live-restore": true
|
||||||
|
}'
|
||||||
|
|
||||||
log() {
|
log() {
|
||||||
echo -e "\033[1;32m$1\033[0m"
|
echo -e "\033[1;32m$1\033[0m"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
warning() {
|
||||||
|
echo -e "\033[1;33m$1\033[0m"
|
||||||
|
}
|
||||||
|
|
||||||
error() {
|
error() {
|
||||||
echo -e "\033[1;31mERROR: $1\033[0m" >&2
|
echo -e "\033[1;31mERROR: $1\033[0m" >&2
|
||||||
exit 1
|
exit 1
|
||||||
@@ -52,12 +67,35 @@ install_docker() {
|
|||||||
if command_exists dockerd; then
|
if command_exists dockerd; then
|
||||||
log "✓ Docker is already installed."
|
log "✓ Docker is already installed."
|
||||||
docker version
|
docker version
|
||||||
|
|
||||||
|
DOCKER_ALREADY_INSTALLED=true
|
||||||
|
# Check if the installed Docker configured to use the containerd image store.
|
||||||
|
local driver_status
|
||||||
|
driver_status=$(docker info -f '{{ .DriverStatus }}' 2>/dev/null)
|
||||||
|
if [[ "$driver_status" == *"io.containerd.snapshotter"* ]]; then
|
||||||
|
CONTAINERD_IMAGE_STORE_ENABLED="true"
|
||||||
|
fi
|
||||||
|
|
||||||
return
|
return
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "⏳ Installing Docker..."
|
log "⏳ Installing Docker..."
|
||||||
curl -fsSL https://get.docker.com | sh
|
curl -fsSL https://get.docker.com | sh
|
||||||
log "✓ Docker installed successfully."
|
|
||||||
|
# Configure Docker daemon for new installation.
|
||||||
|
# Create Docker daemon config directory if it doesn't exist.
|
||||||
|
local docker_config_dir
|
||||||
|
docker_config_dir=$(dirname "${DOCKER_DAEMON_CONFIG_FILE}")
|
||||||
|
if [ ! -d "${docker_config_dir}" ]; then
|
||||||
|
mkdir -p "${docker_config_dir}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "⏳ Configuring Docker daemon (${DOCKER_DAEMON_CONFIG_FILE}) to optimise it for Uncloud..."
|
||||||
|
echo "${DOCKER_DAEMON_CONFIG}" > "${DOCKER_DAEMON_CONFIG_FILE}"
|
||||||
|
|
||||||
|
systemctl restart docker
|
||||||
|
|
||||||
|
log "✓ Docker installed and configured successfully."
|
||||||
}
|
}
|
||||||
|
|
||||||
create_uncloud_user_and_group() {
|
create_uncloud_user_and_group() {
|
||||||
@@ -271,4 +309,34 @@ install_corrosion
|
|||||||
install_corrosion_systemd
|
install_corrosion_systemd
|
||||||
start_uncloud
|
start_uncloud
|
||||||
|
|
||||||
|
# Show warning if Docker was already installed without containerd image store enabled.
|
||||||
|
if [ "$DOCKER_ALREADY_INSTALLED" = "true" ] && [ "$CONTAINERD_IMAGE_STORE_ENABLED" = "false" ]; then
|
||||||
|
echo ""
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
warning "⚠️ IMPORTANT: Containerd image store configuration"
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
warning "Docker was already installed on the machine but it doesn't use the containerd"
|
||||||
|
warning "image store. Uncloud works best with the containerd image store enabled in Docker."
|
||||||
|
warning "It allows Docker to directly use the images stores in containerd (pushed with"
|
||||||
|
warning "'uc image push') without duplicating them in Docker. This saves disk space and"
|
||||||
|
warning "makes image management more efficient."
|
||||||
|
echo ""
|
||||||
|
warning "See https://docs.docker.com/engine/storage/containerd/ for more details."
|
||||||
|
echo ""
|
||||||
|
warning "To enable it, run the following commands on the machine:"
|
||||||
|
echo ""
|
||||||
|
echo "sudo bash -c 'cat > ${DOCKER_DAEMON_CONFIG_FILE} << EOF"
|
||||||
|
echo "${DOCKER_DAEMON_CONFIG}"
|
||||||
|
echo "EOF'"
|
||||||
|
echo "sudo systemctl restart docker"
|
||||||
|
echo ""
|
||||||
|
warning "WARNING: Switching to containerd image store causes you to temporarily lose images"
|
||||||
|
warning "and containers created using the classic storage driver. Those resources still"
|
||||||
|
warning "exist on your filesystem, and you can retrieve them by turning off the containerd"
|
||||||
|
warning "image store feature."
|
||||||
|
warning "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
log "✓ Uncloud installed on the machine successfully! 🎉"
|
log "✓ Uncloud installed on the machine successfully! 🎉"
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ print_manual_install() {
|
|||||||
|
|
||||||
fetch_latest_version() {
|
fetch_latest_version() {
|
||||||
api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
|
api_url="https://api.github.com/repos/${GITHUB_REPO}/releases/latest"
|
||||||
VERSION=$(curl -fsSL "$api_url" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
|
auth_header=""
|
||||||
|
if [ -n "$GITHUB_TOKEN" ]; then
|
||||||
|
auth_header="Authorization: Bearer $GITHUB_TOKEN"
|
||||||
|
fi
|
||||||
|
VERSION=$(curl -fsSL -H "$auth_header" "$api_url" | grep -o '"tag_name": "[^"]*' | cut -d'"' -f4)
|
||||||
if [ -z "$VERSION" ]; then
|
if [ -z "$VERSION" ]; then
|
||||||
echo "Failed to fetch the latest version from GitHub."
|
echo "Failed to fetch the latest version from GitHub."
|
||||||
print_manual_install
|
print_manual_install
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"path"
|
"path"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
composecli "github.com/compose-spec/compose-go/v2/cli"
|
composecli "github.com/compose-spec/compose-go/v2/cli"
|
||||||
@@ -15,7 +16,6 @@ import (
|
|||||||
"github.com/google/go-containerregistry/pkg/name"
|
"github.com/google/go-containerregistry/pkg/name"
|
||||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||||
"github.com/psviderski/uncloud/internal/cli"
|
"github.com/psviderski/uncloud/internal/cli"
|
||||||
cliInternal "github.com/psviderski/uncloud/internal/cli"
|
|
||||||
"github.com/psviderski/uncloud/internal/ucind"
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
"github.com/psviderski/uncloud/pkg/api"
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
"github.com/psviderski/uncloud/pkg/client/compose"
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
@@ -29,7 +29,7 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
clusterName := "ucind-test.compose-build"
|
clusterName := "ucind-test.compose-build"
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
registryInternalPort := "5000/tcp"
|
registryInternalPort := "5001/tcp"
|
||||||
clusterOpts := ucind.CreateClusterOptions{
|
clusterOpts := ucind.CreateClusterOptions{
|
||||||
Machines: 1,
|
Machines: 1,
|
||||||
PortMap: nat.PortMap{
|
PortMap: nat.PortMap{
|
||||||
@@ -79,7 +79,7 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
Ports: []api.PortSpec{
|
Ports: []api.PortSpec{
|
||||||
{
|
{
|
||||||
ContainerPort: 5000,
|
ContainerPort: 5000,
|
||||||
PublishedPort: 5000,
|
PublishedPort: 5001,
|
||||||
Mode: api.PortModeHost,
|
Mode: api.PortModeHost,
|
||||||
Protocol: api.ProtocolTCP,
|
Protocol: api.ProtocolTCP,
|
||||||
},
|
},
|
||||||
@@ -97,8 +97,9 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
servicesToBuild := cliInternal.GetServicesThatNeedBuild(project)
|
servicesToBuild, err := cli.ServicesThatNeedBuild(project, nil, false)
|
||||||
serviceImage1 := fmt.Sprintf("127.0.0.1:%d/service-first", registryHostPort)
|
require.NoError(t, err)
|
||||||
|
serviceImage1 := project.Services["service-first"].Image // contains auto-generated default tag
|
||||||
serviceImage2 := fmt.Sprintf("127.0.0.1:%d/service-second:version2", registryHostPort)
|
serviceImage2 := fmt.Sprintf("127.0.0.1:%d/service-second:version2", registryHostPort)
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
// Remove the images after the test
|
// Remove the images after the test
|
||||||
@@ -109,8 +110,8 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage2)
|
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage2)
|
||||||
})
|
})
|
||||||
|
|
||||||
servicesToBuildExpected := map[string]types.ServiceConfig{
|
servicesToBuildExpected := []types.ServiceConfig{
|
||||||
"service-first": {
|
{
|
||||||
Name: "service-first",
|
Name: "service-first",
|
||||||
Build: &types.BuildConfig{
|
Build: &types.BuildConfig{
|
||||||
Context: path.Join(project.WorkingDir, "service-first-dir"),
|
Context: path.Join(project.WorkingDir, "service-first-dir"),
|
||||||
@@ -122,7 +123,7 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
"default": nil,
|
"default": nil,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"service-second": {
|
{
|
||||||
Name: "service-second",
|
Name: "service-second",
|
||||||
Build: &types.BuildConfig{
|
Build: &types.BuildConfig{
|
||||||
Context: path.Join(project.WorkingDir, "service-second-dir"),
|
Context: path.Join(project.WorkingDir, "service-second-dir"),
|
||||||
@@ -135,22 +136,27 @@ func TestComposeBuild(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
assert.Equal(t, servicesToBuildExpected, servicesToBuild)
|
assert.ElementsMatch(t, servicesToBuildExpected, servicesToBuild)
|
||||||
|
|
||||||
// Build and push the images
|
// Build and push the images to the test registry.
|
||||||
buildOpts := cli.BuildOptions{
|
// Create a minimal CLI instance for the build function (it's only used for cluster push, which we're not using here).
|
||||||
Push: true,
|
uncli := &cli.CLI{}
|
||||||
NoCache: false,
|
buildOpts := cli.BuildServicesOptions{
|
||||||
|
PushRegistry: true,
|
||||||
}
|
}
|
||||||
cli.BuildServices(context.Background(), servicesToBuild, buildOpts)
|
err = uncli.BuildServices(context.Background(), project, buildOpts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Check the image of the first service
|
// Check the image of the first service
|
||||||
ref1, err := name.NewRepository(fmt.Sprintf("127.0.0.1:%d/service-first", registryHostPort))
|
tagSeparatorIdx := strings.LastIndex(serviceImage1, ":")
|
||||||
|
serviceRepo1, serviceTag1 := serviceImage1[:tagSeparatorIdx], serviceImage1[tagSeparatorIdx+1:]
|
||||||
|
|
||||||
|
ref1, err := name.NewRepository(serviceRepo1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
tags, err := remote.List(ref1)
|
tags, err := remote.List(ref1)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
assert.Equal(t, tags, []string{"latest"}, "Tags for service service-first do not match")
|
assert.Equal(t, tags, []string{serviceTag1}, "Tags for service service-first do not match")
|
||||||
|
|
||||||
// Check the image of the second service
|
// Check the image of the second service
|
||||||
ref2, err := name.NewRepository(fmt.Sprintf("127.0.0.1:%d/service-second", registryHostPort))
|
ref2, err := name.NewRepository(fmt.Sprintf("127.0.0.1:%d/service-second", registryHostPort))
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client/compose"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestComposeConfigs(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.compose-configs"
|
||||||
|
ctx := context.Background()
|
||||||
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true)
|
||||||
|
|
||||||
|
machine := c.Machines[0]
|
||||||
|
cli, err := machine.Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("basic configs", func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
name := "web"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
removeServices(t, cli, name)
|
||||||
|
})
|
||||||
|
|
||||||
|
project, err := compose.LoadProject(ctx, []string{"fixtures/compose-configs.yaml"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
deploy, err := compose.NewDeployment(ctx, cli, project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plan, err := deploy.Plan(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, plan.Operations, 1, "Expected 1 service deployment")
|
||||||
|
|
||||||
|
err = deploy.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
svc, err := cli.InspectService(ctx, name)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedSpec := api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Command: []string{"sleep", "600"},
|
||||||
|
Image: "busybox:1.37.0-uclibc",
|
||||||
|
ConfigMounts: []api.ConfigMount{
|
||||||
|
{
|
||||||
|
ConfigName: "from-file",
|
||||||
|
ContainerPath: "/etc/config-from-file.conf",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o644); return &m }(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ConfigName: "from-inline",
|
||||||
|
ContainerPath: "/etc/config-inline.conf",
|
||||||
|
Uid: "1000",
|
||||||
|
Gid: "1000",
|
||||||
|
Mode: func() *os.FileMode { m := os.FileMode(0o600); return &m }(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Configs: []api.ConfigSpec{
|
||||||
|
{
|
||||||
|
Name: "from-file",
|
||||||
|
Content: []byte("this is file config\n"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "from-inline",
|
||||||
|
Content: []byte("this is inline config\n"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Replicas: 1,
|
||||||
|
}
|
||||||
|
assertServiceMatchesSpec(t, svc, expectedSpec)
|
||||||
|
|
||||||
|
// Verify deployment is up-to-date after initial deployment
|
||||||
|
deploy, err = compose.NewDeployment(ctx, cli, project)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
plan, err = deploy.Plan(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, plan.Operations, 0, "Expected no new operations after configs deployment")
|
||||||
|
|
||||||
|
// Verify the config files are actually created in the container and contain expected content
|
||||||
|
containerName := svc.Containers[0].Container.Name
|
||||||
|
|
||||||
|
configContentFirst, err := readFileInfoInContainer(t, cli, name, containerName, "/etc/config-from-file.conf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fileInfo{
|
||||||
|
permissions: 0o644,
|
||||||
|
content: "this is file config\n",
|
||||||
|
userId: 0,
|
||||||
|
groupId: 0,
|
||||||
|
}, configContentFirst)
|
||||||
|
|
||||||
|
configContentSecond, err := readFileInfoInContainer(t, cli, name, containerName, "/etc/config-inline.conf")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, fileInfo{
|
||||||
|
permissions: 0o600,
|
||||||
|
content: "this is inline config\n",
|
||||||
|
userId: 1000,
|
||||||
|
groupId: 1000,
|
||||||
|
}, configContentSecond)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/docker/docker/api/types/container"
|
||||||
|
dockerclient "github.com/docker/docker/client"
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestInternalDNS tests the internal DNS functionality including the new machine-specific service lookups
|
||||||
|
func TestInternalDNS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.dns"
|
||||||
|
ctx := context.Background()
|
||||||
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 3}, true)
|
||||||
|
|
||||||
|
cli, err := c.Machines[0].Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a test service with multiple replicas across machines
|
||||||
|
serviceName := "test-dns-service"
|
||||||
|
t.Cleanup(func() {
|
||||||
|
err := cli.RemoveService(ctx, serviceName)
|
||||||
|
if err != nil && !strings.Contains(err.Error(), "not found") {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deploy a service across all machines in global mode using pause container
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: serviceName,
|
||||||
|
Mode: api.ServiceModeGlobal,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "portainer/pause:latest",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
_, err = deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the service to be deployed
|
||||||
|
var svc api.Service
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
svc, err = cli.InspectService(ctx, serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// Should have 3 containers (one per machine) and all should be running
|
||||||
|
if len(svc.Containers) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.Container.State.Status != "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 1*time.Second, "Service should be deployed and running on all machines")
|
||||||
|
|
||||||
|
// Get Docker client to access ucind machine containers (shared across all DNS tests)
|
||||||
|
dockerCli, err := dockerclient.NewClientWithOpts(dockerclient.FromEnv, dockerclient.WithAPIVersionNegotiation())
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Helper function to create DNS query service spec
|
||||||
|
createDNSQuerySpec := func(name, dnsQuery, outputFile string) api.ServiceSpec {
|
||||||
|
return api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Mode: api.ServiceModeReplicated,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "wbitt/network-multitool",
|
||||||
|
Command: []string{"sh", "-c", fmt.Sprintf("nslookup %s > %s 2>&1 && echo 'DNS query completed' && sleep infinity",
|
||||||
|
dnsQuery, outputFile)},
|
||||||
|
VolumeMounts: []api.VolumeMount{
|
||||||
|
{
|
||||||
|
VolumeName: "host-tmp",
|
||||||
|
ContainerPath: "/tmp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Volumes: []api.VolumeSpec{
|
||||||
|
{
|
||||||
|
Name: "host-tmp",
|
||||||
|
Type: "bind",
|
||||||
|
BindOptions: &api.BindOptions{
|
||||||
|
HostPath: "/tmp",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Replicas: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to run DNS query and get results
|
||||||
|
runDNSQuery := func(t *testing.T, serviceName, dnsQuery, outputFile string) string {
|
||||||
|
spec := createDNSQuerySpec(serviceName, dnsQuery, outputFile)
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
cli.RemoveService(ctx, spec.Name)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Run the DNS query service
|
||||||
|
resp, err := cli.RunService(ctx, spec)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for the DNS query to complete
|
||||||
|
var querySvc api.Service
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
querySvc, err = cli.InspectService(ctx, resp.ID)
|
||||||
|
if err != nil || len(querySvc.Containers) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return querySvc.Containers[0].Container.State.Status == "running"
|
||||||
|
}, 30*time.Second, 1*time.Second, "DNS query container should be running")
|
||||||
|
|
||||||
|
// Give some time for DNS query to complete and write to file
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
|
||||||
|
// Find which ucind machine the DNS query container is running on
|
||||||
|
ctr := querySvc.Containers[0]
|
||||||
|
machineID := ctr.MachineID
|
||||||
|
|
||||||
|
// Get the ucind machine container name for this machine
|
||||||
|
var ucindMachineName string
|
||||||
|
for _, machine := range c.Machines {
|
||||||
|
if machine.ID == machineID {
|
||||||
|
ucindMachineName = machine.ContainerName
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, ucindMachineName, "Should find ucind machine container name")
|
||||||
|
|
||||||
|
// Read the DNS results from the ucind machine's filesystem
|
||||||
|
exec, err := dockerCli.ContainerExecCreate(ctx, ucindMachineName, container.ExecOptions{
|
||||||
|
Cmd: []string{"cat", outputFile},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
resp2, err := dockerCli.ContainerExecAttach(ctx, exec.ID, container.ExecAttachOptions{})
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer resp2.Close()
|
||||||
|
|
||||||
|
// Read the DNS output
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
n, _ := resp2.Reader.Read(buf)
|
||||||
|
return string(buf[:n])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to verify DNS output doesn't contain errors
|
||||||
|
assertNoDNSErrors := func(t *testing.T, dnsOutput string) {
|
||||||
|
assert.NotContains(t, dnsOutput, "can't resolve", "DNS query should not contain resolution errors")
|
||||||
|
assert.NotContains(t, dnsOutput, "Name or service not known",
|
||||||
|
"DNS query should not contain unknown service errors")
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("service name resolves to all container IPs", func(t *testing.T) {
|
||||||
|
dnsOutput := runDNSQuery(t, "dns-test-service-name", serviceName+".internal", "/tmp/dns_result.txt")
|
||||||
|
t.Logf("DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
|
// Verify that all service container IPs are in the DNS response
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.Contains(t, dnsOutput, containerIP,
|
||||||
|
"Service DNS should resolve to container IP %s", containerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("machine-specific service DNS lookups", func(t *testing.T) {
|
||||||
|
// Test the new <machine-id>.m.<service-name>.internal DNS feature
|
||||||
|
for i, targetContainer := range svc.Containers {
|
||||||
|
targetMachineID := targetContainer.MachineID
|
||||||
|
targetContainerIP := targetContainer.Container.UncloudNetworkIP().String()
|
||||||
|
|
||||||
|
// Construct the machine-specific DNS name
|
||||||
|
machineSpecificDNS := targetMachineID + ".m." + serviceName + ".internal"
|
||||||
|
outputFile := fmt.Sprintf("/tmp/dns_result_machine_%d.txt", i)
|
||||||
|
|
||||||
|
dnsOutput := runDNSQuery(t, fmt.Sprintf("dns-test-machine-%d", i), machineSpecificDNS, outputFile)
|
||||||
|
t.Logf("Machine-specific DNS query output for %s:\n%s", machineSpecificDNS, dnsOutput)
|
||||||
|
|
||||||
|
// Verify that the specific container IP is returned
|
||||||
|
assert.Contains(t, dnsOutput, targetContainerIP,
|
||||||
|
"Machine-specific DNS %s should resolve to container IP %s",
|
||||||
|
machineSpecificDNS, targetContainerIP)
|
||||||
|
|
||||||
|
// Verify that other container IPs are not returned (machine-specific should return only one IP)
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.MachineID != targetMachineID {
|
||||||
|
otherContainerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.NotContains(t, dnsOutput, otherContainerIP,
|
||||||
|
"Machine-specific DNS %s should not resolve to other container IP %s",
|
||||||
|
machineSpecificDNS, otherContainerIP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("service ID DNS lookup", func(t *testing.T) {
|
||||||
|
// Test that service ID also resolves (existing functionality)
|
||||||
|
dnsOutput := runDNSQuery(t, "dns-test-service-id", svc.ID+".internal", "/tmp/dns_result_service_id.txt")
|
||||||
|
t.Logf("Service ID DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
|
// Verify that all service container IPs are in the DNS response
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
containerIP := ctr.Container.UncloudNetworkIP().String()
|
||||||
|
assert.Contains(t, dnsOutput, containerIP,
|
||||||
|
"Service ID DNS should resolve to container IP %s", containerIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("nearest mode prioritizes local subnet IPs", func(t *testing.T) {
|
||||||
|
// Test the "nearest" mode which should sort local subnet IPs first
|
||||||
|
dnsOutput := runDNSQuery(t, "dns-test-nearest", "nearest."+serviceName+".internal", "/tmp/dns_result_nearest.txt")
|
||||||
|
t.Logf("Nearest mode DNS query output:\n%s", dnsOutput)
|
||||||
|
|
||||||
|
// Find which machine ran the query
|
||||||
|
querySvc, err := cli.InspectService(ctx, "dns-test-nearest")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEmpty(t, querySvc.Containers, "Query service should have a container")
|
||||||
|
queryMachineID := querySvc.Containers[0].MachineID
|
||||||
|
|
||||||
|
// Find the local container IP (on the same machine as the query)
|
||||||
|
var localIP string
|
||||||
|
for _, ctr := range svc.Containers {
|
||||||
|
if ctr.MachineID == queryMachineID {
|
||||||
|
localIP = ctr.Container.UncloudNetworkIP().String()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, localIP, "Should find local container IP on query machine %s", queryMachineID)
|
||||||
|
|
||||||
|
// Extract the first IP address from the DNS output using regex
|
||||||
|
// Pattern matches: "Name: nearest.test-dns-service.internal" followed by "Address: X.X.X.X"
|
||||||
|
re := regexp.MustCompile(`(?m)Name:\s+[\w\.\-]+\s+Address:\s+([\d\.]+)`)
|
||||||
|
matches := re.FindStringSubmatch(dnsOutput)
|
||||||
|
require.Len(t, matches, 2, "Should find Name's Address in DNS output")
|
||||||
|
firstIP := matches[1]
|
||||||
|
assert.Equal(t, localIP, firstIP,
|
||||||
|
"Nearest mode should return local subnet IP first (query machine: %s, local IP: %s, first DNS result: %s)",
|
||||||
|
queryMachineID, localIP, firstIP)
|
||||||
|
|
||||||
|
assertNoDNSErrors(t, dnsOutput)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,244 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/internal/ucind"
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deployTestService is a helper function to deploy a simple alpine service for testing exec
|
||||||
|
func deployTestService(t *testing.T, ctx context.Context, cli *client.Client, name string, replicas uint) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
spec := api.ServiceSpec{
|
||||||
|
Name: name,
|
||||||
|
Replicas: replicas,
|
||||||
|
Container: api.ContainerSpec{
|
||||||
|
Image: "alpine:3.20",
|
||||||
|
Command: []string{"sleep", "3600"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
deployment := cli.NewDeployment(spec, nil)
|
||||||
|
err := deployment.Validate(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = deployment.Run(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Wait for all replicas to be running
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
service, err := cli.InspectService(ctx, name)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if uint(len(service.Containers)) != replicas {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, ctr := range service.Containers {
|
||||||
|
if ctr.Container.State.Status != "running" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}, 30*time.Second, 1*time.Second, fmt.Sprintf("service %s should have %d running replicas", name, replicas))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestExecBasicCommands tests basic command execution, errors, and stderr handling
|
||||||
|
func TestExecBasicCommands(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
clusterName := "ucind-test.exec-basic"
|
||||||
|
ctx := context.Background()
|
||||||
|
c, _ := createTestCluster(t, clusterName, ucind.CreateClusterOptions{Machines: 1}, true)
|
||||||
|
|
||||||
|
cli, err := c.Machines[0].Connect(ctx)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test: Non-existent service should fail
|
||||||
|
t.Run("non-existent service", func(t *testing.T) {
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"echo", "test"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cli.ExecContainer(ctx, "non-existent-service", "", execOptions)
|
||||||
|
assert.Error(t, err, "should fail for non-existent service")
|
||||||
|
assert.Contains(t, strings.ToLower(err.Error()), "inspect service: not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deploy a simple service for all remaining tests
|
||||||
|
serviceName := "test-exec-service"
|
||||||
|
deployTestService(t, ctx, cli, serviceName, 1)
|
||||||
|
|
||||||
|
// Test: Execute a simple echo command
|
||||||
|
t.Run("echo command", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"echo", "hello world"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, exitCode, "command should exit with code 0")
|
||||||
|
assert.Equal(t, "hello world\n", stdout.String(), "should capture stdout")
|
||||||
|
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Command with non-zero exit code
|
||||||
|
t.Run("non-zero exit code", func(t *testing.T) {
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"sh", "-c", "exit 42"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 42, exitCode, "should return actual exit code")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Invalid command should fail
|
||||||
|
t.Run("invalid command", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"nonexistent-command"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
require.NoError(t, err, "exec should not fail, but command should return non-zero exit")
|
||||||
|
assert.Equal(t, 126, exitCode, "invalid command should return non-zero exit code")
|
||||||
|
assert.Contains(t, stdout.String(), "executable file not found")
|
||||||
|
assert.Equal(t, "", stderr.String(), "stderr should be empty")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Non-existent container ID
|
||||||
|
t.Run("non-existent container", func(t *testing.T) {
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"echo", "test"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cli.ExecContainer(ctx, serviceName, "non-existent-container-id", execOptions)
|
||||||
|
assert.Error(t, err, "should fail for non-existent container")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Empty command
|
||||||
|
t.Run("empty command", func(t *testing.T) {
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
assert.Error(t, err, "should fail for empty command")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Command with both stdout and stderr
|
||||||
|
t.Run("mixed stdout/stderr", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"sh", "-c", "echo 'stdout'; echo 'stderr' >&2"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
assert.Equal(t, "stdout\n", stdout.String(), "should capture stdout")
|
||||||
|
assert.Equal(t, "stderr\n", stderr.String(), "should capture stderr")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Detached command should return immediately
|
||||||
|
t.Run("detached command", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
start := time.Now()
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"sh", "-c", "sleep 10; echo hello"}, // Long-running command
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
Detach: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
// Should return immediately, not wait for command to complete
|
||||||
|
assert.Less(t, elapsed, 5*time.Second, "detached command should return quickly")
|
||||||
|
assert.Empty(t, stdout.String(), "stdout should be empty for detached command")
|
||||||
|
assert.Empty(t, stderr.String(), "stderr should be empty for detached command")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test: Detached mode with invalid command
|
||||||
|
t.Run("detached invalid command", func(t *testing.T) {
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"nonexistent-detached-command"},
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
Detach: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceName, "", execOptions)
|
||||||
|
|
||||||
|
require.ErrorContains(t, err, "executable file not found")
|
||||||
|
assert.Equal(t, 1, exitCode)
|
||||||
|
assert.Empty(t, stdout.String(), "stdout should be empty for detached command")
|
||||||
|
assert.Empty(t, stderr.String(), "stderr should be empty for detached command")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Deploy a service with multiple replicas
|
||||||
|
multiServiceName := "multi-replica-service"
|
||||||
|
deployTestService(t, ctx, cli, multiServiceName, 2)
|
||||||
|
|
||||||
|
// Test: Execute command on specific container
|
||||||
|
t.Run("exec on specific container", func(t *testing.T) {
|
||||||
|
service, err := cli.InspectService(ctx, multiServiceName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.GreaterOrEqual(t, len(service.Containers), 2, "should have at least 2 containers")
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
|
||||||
|
// Use container prefix
|
||||||
|
containerIDPrefix := service.Containers[1].Container.ID[:3]
|
||||||
|
execOptions := api.ExecOptions{
|
||||||
|
Command: []string{"hostname"},
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, multiServiceName, containerIDPrefix, execOptions)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, exitCode)
|
||||||
|
assert.Greater(t, len(stdout.String()), 4)
|
||||||
|
assert.Equal(t, service.Containers[1].Container.Name+"\n", stdout.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: busybox:1.37.0-uclibc
|
||||||
|
command: ["sleep", "600"]
|
||||||
|
configs:
|
||||||
|
- source: from-file
|
||||||
|
target: /etc/config-from-file.conf
|
||||||
|
mode: 0644
|
||||||
|
- source: from-inline
|
||||||
|
target: /etc/config-inline.conf
|
||||||
|
uid: "1000"
|
||||||
|
gid: "1000"
|
||||||
|
mode: 0600
|
||||||
|
deploy:
|
||||||
|
replicas: 1
|
||||||
|
configs:
|
||||||
|
from-file:
|
||||||
|
file: ./configs/test-config.conf
|
||||||
|
from-inline:
|
||||||
|
content: |
|
||||||
|
this is inline config
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
this is file config
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package e2e
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/psviderski/uncloud/pkg/api"
|
||||||
|
"github.com/psviderski/uncloud/pkg/client"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fileInfo struct {
|
||||||
|
permissions os.FileMode
|
||||||
|
content string
|
||||||
|
userId int
|
||||||
|
groupId int
|
||||||
|
}
|
||||||
|
|
||||||
|
// execInContainerAndReadOutput is a helper that executes a command in a container and returns stdout output.
|
||||||
|
// It validates the exit code is 0 and that there's no stderr output.
|
||||||
|
func execInContainerAndReadOutput(
|
||||||
|
t *testing.T,
|
||||||
|
ctx context.Context,
|
||||||
|
cli *client.Client,
|
||||||
|
serviceNameOrID, containerNameOrID string,
|
||||||
|
command []string,
|
||||||
|
) (string, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
execOpts := api.ExecOptions{
|
||||||
|
Command: command,
|
||||||
|
AttachStdout: true,
|
||||||
|
AttachStderr: true,
|
||||||
|
Stdout: &stdout,
|
||||||
|
Stderr: &stderr,
|
||||||
|
}
|
||||||
|
|
||||||
|
commandName := command[0]
|
||||||
|
|
||||||
|
exitCode, err := cli.ExecContainer(ctx, serviceNameOrID, containerNameOrID, execOpts)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("exec %s: %w", commandName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, 0, exitCode, "Expected exit code 0 from %s command", commandName)
|
||||||
|
|
||||||
|
if stderr.Len() > 0 {
|
||||||
|
return "", fmt.Errorf("%s stderr output: %s", commandName, stderr.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFileInfoInContainer reads file information from a container using the Uncloud client ExecContainer API.
|
||||||
|
// Information read: permissions, uid, gid, content.
|
||||||
|
func readFileInfoInContainer(t *testing.T, cli *client.Client, serviceNameOrID, containerNameOrID, filePath string) (fileInfo, error) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Get file permissions
|
||||||
|
permOutput, err := execInContainerAndReadOutput(t, ctx, cli, serviceNameOrID, containerNameOrID,
|
||||||
|
[]string{"stat", "-c", "%a %u %g", filePath})
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse permissions
|
||||||
|
permissions := strings.TrimSpace(permOutput)
|
||||||
|
fmt.Printf("Permissions output: %s\n", permissions)
|
||||||
|
|
||||||
|
// Parse three numbers: permissions, uid, gid
|
||||||
|
var permissionsOctal, uid, gid int
|
||||||
|
_, err = fmt.Sscanf(permissions, "%o %d %d", &permissionsOctal, &uid, &gid)
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, fmt.Errorf("parse stat output: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := os.FileMode(permissionsOctal)
|
||||||
|
|
||||||
|
// Get file content
|
||||||
|
fileContent, err := execInContainerAndReadOutput(t, ctx, cli, serviceNameOrID, containerNameOrID,
|
||||||
|
[]string{"cat", filePath})
|
||||||
|
if err != nil {
|
||||||
|
return fileInfo{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileInfo{
|
||||||
|
permissions: mode,
|
||||||
|
userId: uid,
|
||||||
|
groupId: gid,
|
||||||
|
content: fileContent,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user