Compare commits

..
21 changed files with 378 additions and 168 deletions
+1
View File
@@ -0,0 +1 @@
github: [psviderski]
+33 -2
View File
@@ -1,5 +1,4 @@
name: Go Tests
on:
push:
branches:
@@ -12,10 +11,13 @@ on:
paths:
- ".github/**"
- "**.go"
- "**.proto"
- "go.*"
- "Makefile"
- "scripts/**"
- "test/**"
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
@@ -29,8 +31,37 @@ jobs:
go-version: "1.23.2"
- name: Install dependencies
run: go mod tidy
run: |
go mod tidy
git diff --exit-code ||
(echo "go.mod or go.sum has changed. Please run 'go mod tidy' and commit the changes." && exit 1)
- name: Run tests
run: make test
timeout-minutes: 10
check-protobuf:
runs-on: ${{ matrix.os }}
timeout-minutes: 10
strategy:
matrix:
# Run on more platforms so that mise lockfile checksums are checked/generated for all platforms.
# See https://github.com/jdx/mise/issues/4276 for details.
os: [ubuntu-latest, macos-latest]
steps:
- name: Checkout code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Install Mise
uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
with:
version: "2025.6.5"
env:
GITHUB_TOKEN: ${{ github.token }}
- name: Generate Protobuf files
run: |
make proto
# check if the generated files are up to date
git diff --exit-code ||
(echo "Generated protobuf files are not up to date. Please run 'make proto' and commit the changes." && exit 1)
+31
View File
@@ -0,0 +1,31 @@
[tools.go]
version = "1.23.10"
backend = "core:go"
[tools.go.checksums]
"go1.23.10.darwin-arm64.tar.gz" = "sha256:25c64bfa8a8fd8e7f62fb54afa4354af8409a4bb2358c2699a1003b733e6fce5"
"go1.23.10.linux-amd64.tar.gz" = "sha256:535f9f81802499f2a7dbfa70abb8fda3793725fcc29460f719815f6e10b5fd60"
[tools.protoc]
version = "27.3"
backend = "aqua:protocolbuffers/protobuf/protoc"
[tools.protoc.checksums]
"protoc-27.3-linux-x86_64.zip" = "sha256:6dab2adab83f915126cab53540d48957c40e9e9023969c3e84d44bfb936c7741"
"protoc-27.3-osx-aarch_64.zip" = "sha256:b22116bd97cdbd7ea25346abe635a9df268515fe5ef5afa93cd9a68fc2513f84"
[tools.protoc-gen-go]
version = "1.34.2"
backend = "aqua:protocolbuffers/protobuf-go/protoc-gen-go"
[tools.protoc-gen-go.checksums]
"protoc-gen-go.v1.34.2.darwin.arm64.tar.gz" = "sha256:17aca7f948dbb624049030cf841e35895cf34183ba006e721247fdeb95ff2780"
"protoc-gen-go.v1.34.2.linux.amd64.tar.gz" = "sha256:b87bc134dee55576a842141bf0ed27761c635d746780fce5dee038c6dd16554f"
[tools.protoc-gen-go-grpc]
version = "1.5.1"
backend = "aqua:grpc/grpc-go/protoc-gen-go-grpc"
[tools.protoc-gen-go-grpc.checksums]
"protoc-gen-go-grpc.v1.5.1.darwin.arm64.tar.gz" = "sha256:d6083feb51dcfe59f26793e99ef01ef5eac68b64294ec2546711f614ac5878f3"
"protoc-gen-go-grpc.v1.5.1.linux.amd64.tar.gz" = "sha256:a6cac4ea731e54aea304ad44d704a69d1cdc82997084b33637e21a89dc9229d6"
+6
View File
@@ -1,5 +1,11 @@
[settings]
experimental = true
[tools]
go = "1.23"
protoc = "27.3"
protoc-gen-go = "1.34.2"
protoc-gen-go-grpc = "1.5.1"
[env]
_.file = ".env"
-6
View File
@@ -92,9 +92,3 @@ vet:
.PHONY: docs-image-push
docs-image:
docker buildx build --push --platform linux/amd64,linux/arm64 -t "$(DOCS_IMAGE)" ./docs
.PHONY: install-dev
install-dev:
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.34.2
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.5.1
# TODO: install a pinned protoc version
+11 -2
View File
@@ -7,6 +7,7 @@
<a href="https://docs.uncloud.run"><img src="https://img.shields.io/badge/Docs-blue.svg?style=for-the-badge&logo=gitbook&logoColor=white" alt="Documentation"></a>
<a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
<a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
<a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
</p>
</div>
@@ -44,6 +45,11 @@ complexity of Kubernetes.
* **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.
### 🚀 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
The screenshot below demonstrates how I use Uncloud to deploy the [Uncloud Documentation](https://docs.uncloud.run)
@@ -278,8 +284,11 @@ and there may be breaking changes between releases.
We'd love your input! Here's how you can contribute:
* 🐛 Found a bug? [Open an issue](https://github.com/psviderski/uncloud/issues)
* 💡 Have ideas or need help? [Join our Discord community](https://discord.gg/eR35KQJhPu) where we discuss features,
roadmap, implementation details, and help each other out.
* 💡 Have questions, ideas, or need help?
* Start a discussion or join an existing one in
the [Discussions](https://github.com/psviderski/uncloud/discussions).
* Join our [Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, roadmap, implementation
details, and help each other out.
## 🙏 Inspiration & Acknowledgements
+5 -4
View File
@@ -14,18 +14,18 @@ import (
)
func NewListCommand() *cobra.Command {
var clusterContext string
var contextName string
cmd := &cobra.Command{
Use: "ls",
Aliases: []string{"list"},
Short: "List machines in a cluster.",
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return list(cmd.Context(), uncli, clusterContext)
return list(cmd.Context(), uncli, contextName)
},
}
cmd.Flags().StringVarP(
&clusterContext, "context", "c", "",
&contextName, "context", "c", "",
"Name of the cluster context. (default is the current context)",
)
return cmd
@@ -68,7 +68,8 @@ func list(ctx context.Context, uncli *cli.CLI, clusterName string) error {
}
if _, err = fmt.Fprintf(
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP, strings.Join(endpoints, ", "),
tw, "%s\t%s\t%s\t%s\t%s\n", m.Name, capitalise(member.State.String()), subnet, publicIP,
strings.Join(endpoints, ", "),
); err != nil {
return fmt.Errorf("write row: %w", err)
}
+139
View File
@@ -0,0 +1,139 @@
package machine
import (
"context"
"fmt"
"maps"
"slices"
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/lipgloss/tree"
"github.com/docker/docker/api/types/container"
"github.com/psviderski/uncloud/internal/cli"
"github.com/psviderski/uncloud/pkg/api"
"github.com/spf13/cobra"
)
type removeOptions struct {
force bool
yes bool
context string
}
func NewRmCommand() *cobra.Command {
opts := removeOptions{}
cmd := &cobra.Command{
Use: "rm MACHINE",
Aliases: []string{"remove", "delete"},
Short: "Remove a machine from a cluster.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
uncli := cmd.Context().Value("cli").(*cli.CLI)
return remove(cmd.Context(), uncli, args[0], opts)
},
}
cmd.Flags().StringVarP(&opts.context, "context", "c", "",
"Name of the cluster context. (default is the current context)")
cmd.Flags().BoolVarP(&opts.yes, "yes", "y", false,
"Do not prompt for confirmation before removing the machine.")
return cmd
}
func remove(ctx context.Context, uncli *cli.CLI, machineName string, opts removeOptions) error {
client, err := uncli.ConnectCluster(ctx, opts.context)
if err != nil {
return fmt.Errorf("connect to cluster: %w", err)
}
defer client.Close()
// Verify the machine exists and list all service containers on it including stopped ones.
listCtx, machines, err := api.ProxyMachinesContext(ctx, client, []string{machineName})
if err != nil {
return err
}
if len(machines) == 0 {
return fmt.Errorf("machine '%s' not found in the cluster", machineName)
}
m := machines[0].Machine
listOpts := container.ListOptions{All: true}
machineContainers, err := client.Docker.ListServiceContainers(listCtx, "", listOpts)
if err != nil {
return fmt.Errorf("list containers: %w", err)
}
containers := machineContainers[0].Containers
if len(containers) > 0 {
fmt.Printf("Found %d service containers on machine '%s':\n\n", len(containers), m.Name)
fmt.Println(formatContainerTree(containers))
fmt.Println()
fmt.Println("This will remove all service containers on the machine, reset it to the uninitialised state, " +
"and remove it from the cluster.")
} else {
fmt.Printf("No service containers found on machine '%s'.\n", m.Name)
fmt.Println("This will reset the machine to the uninitialised state and remove it from the cluster.")
}
if !opts.yes {
confirmed, err := cli.Confirm()
if err != nil {
return fmt.Errorf("confirm removal: %w", err)
}
if !confirmed {
fmt.Println("Cancelled. Machine was not removed.")
return nil
}
}
// TODO: 3. Remove all service containers on the machine.
// TODO: 4. Implement and call ResetMachine via Machine API to reset the machine state to uninitialised.
// TODO: 5. Remove the machine from the cluster store.
fmt.Printf("Machine '%s' removed from the cluster.\n", m.Name)
return nil
}
// formatContainerTree formats a list of containers grouped by service as a tree structure.
func formatContainerTree(containers []api.ServiceContainer) string {
if len(containers) == 0 {
return ""
}
// Group containers by service.
serviceContainers := make(map[string][]api.ServiceContainer)
for _, ctr := range containers {
serviceName := ctr.ServiceName()
serviceContainers[serviceName] = append(serviceContainers[serviceName], ctr)
}
// Build tree output.
var output []string
serviceNames := slices.Sorted(maps.Keys(serviceContainers))
for _, serviceName := range serviceNames {
ctrs := serviceContainers[serviceName]
mode := ctrs[0].ServiceMode()
// Format a tree for the service with its containers.
plural := ""
if len(ctrs) > 1 {
plural = "s"
}
t := tree.Root(fmt.Sprintf("• %s (%s, %d container%s)", serviceName, mode, len(ctrs), plural)).
EnumeratorStyle(lipgloss.NewStyle().MarginLeft(2).MarginRight(1))
// Add containers as children.
for _, ctr := range ctrs {
state, _ := ctr.HumanState()
info := fmt.Sprintf("%s • %s • %s", ctr.Name, ctr.Config.Image, state)
t.Child(info)
}
output = append(output, t.String())
}
return strings.Join(output, "\n")
}
+1
View File
@@ -14,6 +14,7 @@ func NewRootCommand() *cobra.Command {
NewAddCommand(),
NewInitCommand(),
NewListCommand(),
NewRmCommand(),
NewTokenCommand(),
)
return cmd
-1
View File
@@ -55,5 +55,4 @@ func main() {
}()
cobra.CheckErr(cmd.ExecuteContext(ctx))
}
@@ -169,7 +169,7 @@ as HTTPS endpoint on the previously reserved domain via Caddy.
uc run --name excalidraw --publish 80/https excalidraw/excalidraw
```
You'll the progress of the deployment and the public URL where you can access the service:
You'll see the progress of the deployment and the public URL where you can access the service:
```
[+] Running service excalidraw (replicated mode) 2/2
+1 -1
View File
@@ -37,6 +37,7 @@ require (
github.com/jmoiron/sqlx v1.4.0
github.com/lmittmann/tint v1.0.5
github.com/miekg/dns v1.1.65
github.com/moby/term v0.5.0
github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.0
github.com/siderolabs/discovery-api v0.1.4
@@ -215,7 +216,6 @@ require (
github.com/moby/sys/signal v0.7.1 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/mr-tron/base58 v1.2.0 // indirect
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
+10 -31
View File
@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
composetypes "github.com/compose-spec/compose-go/v2/types"
@@ -15,7 +14,9 @@ import (
"github.com/docker/docker/api/types/image"
dockerclient "github.com/docker/docker/client"
"github.com/docker/docker/pkg/archive"
"github.com/docker/docker/pkg/jsonmessage"
"github.com/docker/docker/registry"
"github.com/moby/term"
)
type BuildOptions struct {
@@ -102,19 +103,10 @@ func buildSingleService(ctx context.Context, dockerCli *dockerclient.Client, ser
}
defer buildResponse.Body.Close()
// Print the build output
decoder := json.NewDecoder(buildResponse.Body)
for {
var message map[string]interface{}
if err := decoder.Decode(&message); err == io.EOF {
break
} else if err != nil {
return "", fmt.Errorf("failed to decode build output for service %s: %w", service.Name, err)
}
if stream, ok := message["stream"]; ok {
fmt.Print(stream)
}
// 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
@@ -161,24 +153,11 @@ func pushSingleServiceImage(ctx context.Context, dockerCli *dockerclient.Client,
}
defer pushResponse.Close()
fmt.Printf("Pushing image %s for service %s\n", imageName, serviceName)
fmt.Printf("Pushing image %s for service %s...\n", imageName, serviceName)
// Handle output and errors
decoder := json.NewDecoder(pushResponse)
for {
var message map[string]interface{}
if err := decoder.Decode(&message); err == io.EOF {
break
} else if err != nil {
return fmt.Errorf("failed to decode push output for image %s: %w", imageName, err)
}
if stream, ok := message["stream"]; ok {
fmt.Print(stream)
} else if errorMessage, ok := message["error"]; ok {
return fmt.Errorf("error pushing image %s: %s", imageName, errorMessage)
} else if status, ok := message["status"]; ok {
fmt.Printf(" %s\n", status)
}
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)
+110 -110
View File
@@ -21,63 +21,6 @@ const (
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type CheckPrerequisitesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Overall status of the checks.
Satisfied bool `protobuf:"varint,1,opt,name=satisfied,proto3" json:"satisfied,omitempty"`
// Error message if not satisfied (empty if all checks pass).
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *CheckPrerequisitesResponse) Reset() {
*x = CheckPrerequisitesResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CheckPrerequisitesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPrerequisitesResponse) ProtoMessage() {}
func (x *CheckPrerequisitesResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPrerequisitesResponse.ProtoReflect.Descriptor instead.
func (*CheckPrerequisitesResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{0}
}
func (x *CheckPrerequisitesResponse) GetSatisfied() bool {
if x != nil {
return x.Satisfied
}
return false
}
func (x *CheckPrerequisitesResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type MachineInfo struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -92,7 +35,7 @@ type MachineInfo struct {
func (x *MachineInfo) Reset() {
*x = MachineInfo{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -105,7 +48,7 @@ func (x *MachineInfo) String() string {
func (*MachineInfo) ProtoMessage() {}
func (x *MachineInfo) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -118,7 +61,7 @@ func (x *MachineInfo) ProtoReflect() protoreflect.Message {
// Deprecated: Use MachineInfo.ProtoReflect.Descriptor instead.
func (*MachineInfo) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{1}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{0}
}
func (x *MachineInfo) GetId() string {
@@ -163,7 +106,7 @@ type NetworkConfig struct {
func (x *NetworkConfig) Reset() {
*x = NetworkConfig{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -176,7 +119,7 @@ func (x *NetworkConfig) String() string {
func (*NetworkConfig) ProtoMessage() {}
func (x *NetworkConfig) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -189,7 +132,7 @@ func (x *NetworkConfig) ProtoReflect() protoreflect.Message {
// Deprecated: Use NetworkConfig.ProtoReflect.Descriptor instead.
func (*NetworkConfig) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{2}
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{1}
}
func (x *NetworkConfig) GetSubnet() *IPPrefix {
@@ -220,6 +163,63 @@ func (x *NetworkConfig) GetPublicKey() []byte {
return nil
}
type CheckPrerequisitesResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
// Overall status of the checks.
Satisfied bool `protobuf:"varint,1,opt,name=satisfied,proto3" json:"satisfied,omitempty"`
// Error message if not satisfied (empty if all checks pass).
Error string `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
}
func (x *CheckPrerequisitesResponse) Reset() {
*x = CheckPrerequisitesResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *CheckPrerequisitesResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*CheckPrerequisitesResponse) ProtoMessage() {}
func (x *CheckPrerequisitesResponse) ProtoReflect() protoreflect.Message {
mi := &file_internal_machine_api_pb_machine_proto_msgTypes[2]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use CheckPrerequisitesResponse.ProtoReflect.Descriptor instead.
func (*CheckPrerequisitesResponse) Descriptor() ([]byte, []int) {
return file_internal_machine_api_pb_machine_proto_rawDescGZIP(), []int{2}
}
func (x *CheckPrerequisitesResponse) GetSatisfied() bool {
if x != nil {
return x.Satisfied
}
return false
}
func (x *CheckPrerequisitesResponse) GetError() string {
if x != nil {
return x.Error
}
return ""
}
type InitClusterRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
@@ -697,31 +697,31 @@ var file_internal_machine_api_pb_machine_proto_rawDesc = []byte{
0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x24, 0x69, 0x6e, 0x74, 0x65, 0x72,
0x6e, 0x61, 0x6c, 0x2f, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f,
0x70, 0x62, 0x2f, 0x63, 0x6f, 0x6d, 0x6d, 0x6f, 0x6e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22,
0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69,
0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a,
0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08,
0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65,
0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f,
0x72, 0x22, 0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66,
0x6f, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69,
0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b,
0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74,
0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70,
0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52,
0x08, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65,
0x74, 0x77, 0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73,
0x75, 0x62, 0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70,
0x69, 0x2e, 0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e,
0x65, 0x74, 0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74,
0x5f, 0x69, 0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x49, 0x50, 0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70,
0x12, 0x29, 0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20,
0x03, 0x28, 0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74,
0x52, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52,
0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49,
0x85, 0x01, 0x0a, 0x0b, 0x4d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x49, 0x6e, 0x66, 0x6f, 0x12,
0x0e, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12,
0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e,
0x61, 0x6d, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72, 0x6b, 0x18, 0x03,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x4e, 0x65, 0x74, 0x77, 0x6f,
0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x6e, 0x65, 0x74, 0x77, 0x6f, 0x72,
0x6b, 0x12, 0x24, 0x0a, 0x09, 0x70, 0x75, 0x62, 0x6c, 0x69, 0x63, 0x5f, 0x69, 0x70, 0x18, 0x04,
0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x52, 0x08, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x49, 0x70, 0x22, 0xae, 0x01, 0x0a, 0x0d, 0x4e, 0x65, 0x74, 0x77,
0x6f, 0x72, 0x6b, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x25, 0x0a, 0x06, 0x73, 0x75, 0x62,
0x6e, 0x65, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0d, 0x2e, 0x61, 0x70, 0x69, 0x2e,
0x49, 0x50, 0x50, 0x72, 0x65, 0x66, 0x69, 0x78, 0x52, 0x06, 0x73, 0x75, 0x62, 0x6e, 0x65, 0x74,
0x12, 0x2c, 0x0a, 0x0d, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x5f, 0x69,
0x70, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x07, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50,
0x52, 0x0c, 0x6d, 0x61, 0x6e, 0x61, 0x67, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x49, 0x70, 0x12, 0x29,
0x0a, 0x09, 0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
0x0b, 0x32, 0x0b, 0x2e, 0x61, 0x70, 0x69, 0x2e, 0x49, 0x50, 0x50, 0x6f, 0x72, 0x74, 0x52, 0x09,
0x65, 0x6e, 0x64, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x73, 0x12, 0x1d, 0x0a, 0x0a, 0x70, 0x75, 0x62,
0x6c, 0x69, 0x63, 0x5f, 0x6b, 0x65, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0c, 0x52, 0x09, 0x70,
0x75, 0x62, 0x6c, 0x69, 0x63, 0x4b, 0x65, 0x79, 0x22, 0x50, 0x0a, 0x1a, 0x43, 0x68, 0x65, 0x63,
0x6b, 0x50, 0x72, 0x65, 0x72, 0x65, 0x71, 0x75, 0x69, 0x73, 0x69, 0x74, 0x65, 0x73, 0x52, 0x65,
0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1c, 0x0a, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73, 0x66,
0x69, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52, 0x09, 0x73, 0x61, 0x74, 0x69, 0x73,
0x66, 0x69, 0x65, 0x64, 0x12, 0x14, 0x0a, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x18, 0x02, 0x20,
0x01, 0x28, 0x09, 0x52, 0x05, 0x65, 0x72, 0x72, 0x6f, 0x72, 0x22, 0xc3, 0x01, 0x0a, 0x12, 0x49,
0x6e, 0x69, 0x74, 0x43, 0x6c, 0x75, 0x73, 0x74, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
0x74, 0x12, 0x20, 0x0a, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e, 0x61, 0x6d, 0x65,
0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x6d, 0x61, 0x63, 0x68, 0x69, 0x6e, 0x65, 0x4e,
@@ -813,9 +813,9 @@ func file_internal_machine_api_pb_machine_proto_rawDescGZIP() []byte {
var file_internal_machine_api_pb_machine_proto_msgTypes = make([]protoimpl.MessageInfo, 11)
var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*CheckPrerequisitesResponse)(nil), // 0: api.CheckPrerequisitesResponse
(*MachineInfo)(nil), // 1: api.MachineInfo
(*NetworkConfig)(nil), // 2: api.NetworkConfig
(*MachineInfo)(nil), // 0: api.MachineInfo
(*NetworkConfig)(nil), // 1: api.NetworkConfig
(*CheckPrerequisitesResponse)(nil), // 2: api.CheckPrerequisitesResponse
(*InitClusterRequest)(nil), // 3: api.InitClusterRequest
(*InitClusterResponse)(nil), // 4: api.InitClusterResponse
(*JoinClusterRequest)(nil), // 5: api.JoinClusterRequest
@@ -830,16 +830,16 @@ var file_internal_machine_api_pb_machine_proto_goTypes = []any{
(*emptypb.Empty)(nil), // 14: google.protobuf.Empty
}
var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
2, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
1, // 0: api.MachineInfo.network:type_name -> api.NetworkConfig
11, // 1: api.MachineInfo.public_ip:type_name -> api.IP
12, // 2: api.NetworkConfig.subnet:type_name -> api.IPPrefix
11, // 3: api.NetworkConfig.management_ip:type_name -> api.IP
13, // 4: api.NetworkConfig.endpoints:type_name -> api.IPPort
12, // 5: api.InitClusterRequest.network:type_name -> api.IPPrefix
11, // 6: api.InitClusterRequest.public_ip:type_name -> api.IP
1, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
1, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
1, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
0, // 7: api.InitClusterResponse.machine:type_name -> api.MachineInfo
0, // 8: api.JoinClusterRequest.machine:type_name -> api.MachineInfo
0, // 9: api.JoinClusterRequest.other_machines:type_name -> api.MachineInfo
10, // 10: api.Service.containers:type_name -> api.Service.Container
7, // 11: api.InspectServiceResponse.service:type_name -> api.Service
14, // 12: api.Machine.CheckPrerequisites:input_type -> google.protobuf.Empty
@@ -848,11 +848,11 @@ var file_internal_machine_api_pb_machine_proto_depIdxs = []int32{
14, // 15: api.Machine.Token:input_type -> google.protobuf.Empty
14, // 16: api.Machine.Inspect:input_type -> google.protobuf.Empty
8, // 17: api.Machine.InspectService:input_type -> api.InspectServiceRequest
0, // 18: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
2, // 18: api.Machine.CheckPrerequisites:output_type -> api.CheckPrerequisitesResponse
4, // 19: api.Machine.InitCluster:output_type -> api.InitClusterResponse
14, // 20: api.Machine.JoinCluster:output_type -> google.protobuf.Empty
6, // 21: api.Machine.Token:output_type -> api.TokenResponse
1, // 22: api.Machine.Inspect:output_type -> api.MachineInfo
0, // 22: api.Machine.Inspect:output_type -> api.MachineInfo
9, // 23: api.Machine.InspectService:output_type -> api.InspectServiceResponse
18, // [18:24] is the sub-list for method output_type
12, // [12:18] is the sub-list for method input_type
@@ -869,18 +869,6 @@ func file_internal_machine_api_pb_machine_proto_init() {
file_internal_machine_api_pb_common_proto_init()
if !protoimpl.UnsafeEnabled {
file_internal_machine_api_pb_machine_proto_msgTypes[0].Exporter = func(v any, i int) any {
switch v := v.(*CheckPrerequisitesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*MachineInfo); i {
case 0:
return &v.state
@@ -892,7 +880,7 @@ func file_internal_machine_api_pb_machine_proto_init() {
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any {
file_internal_machine_api_pb_machine_proto_msgTypes[1].Exporter = func(v any, i int) any {
switch v := v.(*NetworkConfig); i {
case 0:
return &v.state
@@ -904,6 +892,18 @@ func file_internal_machine_api_pb_machine_proto_init() {
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[2].Exporter = func(v any, i int) any {
switch v := v.(*CheckPrerequisitesResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_internal_machine_api_pb_machine_proto_msgTypes[3].Exporter = func(v any, i int) any {
switch v := v.(*InitClusterRequest); i {
case 0:
@@ -32,6 +32,7 @@ const (
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type MachineClient interface {
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
CheckPrerequisites(ctx context.Context, in *emptypb.Empty, opts ...grpc.CallOption) (*CheckPrerequisitesResponse, error)
InitCluster(ctx context.Context, in *InitClusterRequest, opts ...grpc.CallOption) (*InitClusterResponse, error)
JoinCluster(ctx context.Context, in *JoinClusterRequest, opts ...grpc.CallOption) (*emptypb.Empty, error)
@@ -112,6 +113,7 @@ func (c *machineClient) InspectService(ctx context.Context, in *InspectServiceRe
// All implementations must embed UnimplementedMachineServer
// for forward compatibility.
type MachineServer interface {
// CheckPrerequisites verifies if the machine meets all necessary system requirements to participate in the cluster.
CheckPrerequisites(context.Context, *emptypb.Empty) (*CheckPrerequisitesResponse, error)
InitCluster(context.Context, *InitClusterRequest) (*InitClusterResponse, error)
JoinCluster(context.Context, *JoinClusterRequest) (*emptypb.Empty, error)
+6
View File
@@ -345,6 +345,12 @@ func (s *Server) CreateVolume(ctx context.Context, req *pb.CreateVolumeRequest)
return nil, status.Errorf(codes.InvalidArgument, "unmarshal options: %v", err)
}
// Always add the managed label to the volume to indicate that it is managed by Uncloud.
if opts.Labels == nil {
opts.Labels = make(map[string]string)
}
opts.Labels[api.LabelManaged] = ""
vol, err := s.client.VolumeCreate(ctx, opts)
if err != nil {
return nil, status.Error(codes.Internal, err.Error())
+2 -2
View File
@@ -191,10 +191,10 @@ func (nc *networkController) Run(ctx context.Context) error {
})
errGroup.Go(func() error {
slog.Info("Starting Caddyfile controller.")
slog.Info("Starting Caddyconfig controller.")
if err := nc.caddyfileCtrl.Run(ctx); err != nil {
//goland:noinspection GoErrorStringFormat
return fmt.Errorf("Caddyfile controller failed: %w", err)
return fmt.Errorf("Caddyconfig controller failed: %w", err)
}
return nil
})
+11 -4
View File
@@ -30,8 +30,10 @@ const (
PullPolicyNever = "never"
)
var serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
var dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
var (
serviceIDRegexp = regexp.MustCompile("^[0-9a-f]{32}$")
dnsLabelRegexp = regexp.MustCompile(`^[a-z0-9]([-a-z0-9]*[a-z0-9])?$`)
)
func ValidateServiceID(id string) bool {
return serviceIDRegexp.MatchString(id)
@@ -104,7 +106,7 @@ func (s *ServiceSpec) Validate() error {
default:
return fmt.Errorf("invalid mode: %q", s.Mode)
}
if s.Name != "" {
if len(s.Name) > 63 {
return fmt.Errorf("service name too long (max 63 characters): %q", s.Name)
@@ -146,7 +148,6 @@ func (s *ServiceSpec) Validate() error {
return nil
}
func (s *ServiceSpec) Clone() ServiceSpec {
spec := *s
@@ -200,6 +201,12 @@ type ContainerSpec struct {
// SetDefaults returns a copy of the container spec with default values set.
func (s *ContainerSpec) SetDefaults() ContainerSpec {
spec := s.Clone()
if spec.LogDriver == nil {
spec.LogDriver = &LogDriver{
Name: "local",
Options: map[string]string{},
}
}
if spec.PullPolicy == "" {
spec.PullPolicy = PullPolicyMissing
}
-1
View File
@@ -62,7 +62,6 @@ func assertContainerMatchesSpec(t *testing.T, ctr api.ServiceContainer, spec api
assert.Equal(t, spec.Container.Init, ctr.HostConfig.Init)
assert.True(t, strings.HasPrefix(ctr.Name, spec.Name+"-"))
// If LogDriver is not set, any log driver set as default in the Docker daemon config could be used.
if spec.Container.LogDriver != nil {
assert.Equal(t, spec.Container.LogDriver.Name, ctr.HostConfig.LogConfig.Type)
assert.Equal(t, spec.Container.LogDriver.Options, ctr.HostConfig.LogConfig.Config)
+5 -3
View File
@@ -38,7 +38,9 @@ func TestComposeBuild(t *testing.T) {
nat.Port(registryInternalPort): []nat.PortBinding{
{
HostIP: "127.0.0.1",
// Host port is a random available port.
// Explicitly specify the mapped host port for the registry because if not specified,
// 'docker push' from Docker Desktop is unable to reach the randomly mapped one for some reason.
HostPort: "50000",
},
},
},
@@ -103,9 +105,9 @@ func TestComposeBuild(t *testing.T) {
// Remove the images after the test
removeOptions := image.RemoveOptions{Force: true, PruneChildren: true}
_, err := dockerCli.ImageRemove(ctx, serviceImage1, removeOptions)
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup: %w", serviceImage1, err)
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage1)
_, err = dockerCli.ImageRemove(ctx, serviceImage2, removeOptions)
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup: %w", serviceImage2, err)
assert.NoErrorf(t, err, "failed to remove image %s on test cleanup", serviceImage2)
})
servicesToBuildExpected := map[string]types.ServiceConfig{
+3
View File
@@ -121,6 +121,9 @@ func TestComposeDeployment(t *testing.T) {
})
require.NoError(t, err)
assert.Len(t, volumes, 2, "Expected 2 volumes to be created")
// Check that the volumes have the correct labels
assert.Equal(t, map[string]string{"uncloud.managed": ""}, volumes[0].Volume.Labels)
assert.Equal(t, map[string]string{"uncloud.managed": ""}, volumes[1].Volume.Labels)
data1Volume, data2Volume := volumes[0], volumes[1]
if volumes[0].Volume.Name == "test-compose-volumes-data2" {