Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions cmd/uncloud/service/inspect.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package service
import (
"context"
"fmt"
"maps"
"os"
"slices"
"text/tabwriter"
Expand Down Expand Up @@ -59,6 +60,24 @@ func inspect(ctx context.Context, uncli *cli.CLI, opts inspectOptions) error {
fmt.Printf("Service ID: %s\n", svc.ID)
fmt.Printf("Name: %s\n", svc.Name)
fmt.Printf("Mode: %s\n", svc.Mode)

// Display labels from the service spec. Use the first container's spec as representative.
if len(svc.Containers) > 0 {
spec := svc.Containers[0].Container.ServiceSpec
if len(spec.Labels) > 0 {
fmt.Println("Labels:")
for _, key := range slices.Sorted(maps.Keys(spec.Labels)) {
fmt.Printf(" %s=%s\n", key, spec.Labels[key])
}
}
if len(spec.DeployLabels) > 0 {
fmt.Println("Deploy Labels:")
for _, key := range slices.Sorted(maps.Keys(spec.DeployLabels)) {
fmt.Printf(" %s=%s\n", key, spec.DeployLabels[key])
}
}
}

fmt.Println()

// Parse created times for sorting and display.
Expand Down
28 changes: 28 additions & 0 deletions cmd/uncloud/service/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ type runOptions struct {
entrypointChanged bool
env []string
image string
labels []string
machines []string
memory dockeropts.MemBytes
mode string
Expand Down Expand Up @@ -110,6 +111,9 @@ func NewRunCommand(groupID string) *cobra.Command {
" -v postgres-data:/var/lib/postgresql/data Mount volume 'postgres-data' to /var/lib/postgresql/data in container\n"+
" -v /data/uploads:/app/uploads Bind mount /data/uploads host directory to /app/uploads in container\n"+
" -v /host/path:/container/path:ro Bind mount a host directory or file as read-only")
cmd.Flags().StringSliceVarP(&opts.labels, "label", "l", nil,
"Set a label on service containers. Can be specified multiple times.\n"+
"Format: key=value")

return cmd
}
Expand Down Expand Up @@ -199,6 +203,11 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
return spec, err
}

labels, err := parseLabels(opts.labels)
if err != nil {
return spec, err
}

placement := api.Placement{
Machines: cli.ExpandCommaSeparatedValues(opts.machines),
}
Expand All @@ -217,6 +226,7 @@ func prepareServiceSpec(opts runOptions) (api.ServiceSpec, error) {
User: opts.user,
VolumeMounts: mounts,
},
Labels: labels,
Mode: opts.mode,
Name: opts.name,
Placement: placement,
Expand Down Expand Up @@ -275,6 +285,24 @@ func parseEnv(env []string) (api.EnvVars, error) {
return envVars, nil
}

// parseLabels parses label flags in the format "key=value".
func parseLabels(labels []string) (map[string]string, error) {
if len(labels) == 0 {
return nil, nil
}

result := make(map[string]string, len(labels))
for _, l := range labels {
key, value, hasValue := strings.Cut(l, "=")
if key == "" || !hasValue {
return nil, fmt.Errorf("invalid label format '%s': must be key=value", l)
}
result[key] = value
}

return result, nil
}

// parseVolumeFlags parses volume flag values in Docker CLI format and returns VolumeSpecs and VolumeMounts.
// It handles both named volumes (volume_name:/container/path[:ro|volume-nocopy])
// and bind mounts (/host/path:/container/path[:ro]).
Expand Down
414 changes: 249 additions & 165 deletions internal/machine/api/pb/docker.pb.go

Large diffs are not rendered by default.

11 changes: 9 additions & 2 deletions internal/machine/api/pb/docker.proto
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@ syntax = "proto3";

package api;

option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";

import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";
import "internal/machine/api/pb/common.proto";

option go_package = "github.com/psviderski/uncloud/internal/machine/api/pb";

service Docker {
rpc CreateContainer(CreateContainerRequest) returns (CreateContainerResponse);
rpc InspectContainer(InspectContainerRequest) returns (InspectContainerResponse);
Expand All @@ -34,6 +34,7 @@ service Docker {
rpc InspectServiceContainer(InspectContainerRequest) returns (ServiceContainer);
rpc ListServiceContainers(ListServiceContainersRequest) returns (ListServiceContainersResponse);
rpc RemoveServiceContainer(RemoveContainerRequest) returns (google.protobuf.Empty);
rpc UpdateServiceContainerSpec(UpdateServiceContainerSpecRequest) returns (google.protobuf.Empty);
}

message CreateContainerRequest {
Expand Down Expand Up @@ -276,3 +277,9 @@ message MachineServiceContainers {
Metadata metadata = 1;
repeated ServiceContainer containers = 2;
}

message UpdateServiceContainerSpecRequest {
string container_id = 1;
// JSON serialised api.ServiceSpec.
bytes service_spec = 2;
}
82 changes: 62 additions & 20 deletions internal/machine/api/pb/docker_grpc.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion internal/machine/cluster.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ func newClusterController(
dnsServer *dns.Server,
dnsResolver *dns.ClusterResolver,
unregistry *unregistry.Registry,
storeSync <-chan struct{},
) (*clusterController, error) {
slog.Info("Starting WireGuard network.")
wgnet, err := network.NewWireGuardNetwork()
Expand All @@ -82,7 +83,7 @@ func newClusterController(
endpointChanges: endpointChanges,
server: server,
corroService: corroService,
dockerCtrl: docker.NewController(state.ID, dockerService, store),
dockerCtrl: docker.NewController(state.ID, dockerService, store, storeSync),
dockerReady: dockerReady,
clusterReady: clusterReady,
caddyconfigCtrl: caddyfileCtrl,
Expand Down
22 changes: 22 additions & 0 deletions internal/machine/docker/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -536,3 +536,25 @@ func (c *Client) RemoveServiceContainer(ctx context.Context, id string, opts con
}
return err
}

// UpdateServiceContainerSpec updates the stored service spec for a container without recreating it.
// Used for updating metadata like deploy labels that don't require container recreation.
func (c *Client) UpdateServiceContainerSpec(ctx context.Context, containerID string, spec api.ServiceSpec) error {
specBytes, err := json.Marshal(spec)
if err != nil {
return fmt.Errorf("marshal service spec: %w", err)
}

_, err = c.GRPCClient.UpdateServiceContainerSpec(ctx, &pb.UpdateServiceContainerSpecRequest{
ContainerId: containerID,
ServiceSpec: specBytes,
})
if err != nil {
if status.Convert(err).Code() == codes.NotFound {
return errdefs.NotFound(err)
}
return err
}

return nil
}
10 changes: 9 additions & 1 deletion internal/machine/docker/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,14 +30,17 @@ type Controller struct {
client *client.Client
service *Service
store *store.Store
// sync receives signals to trigger an immediate sync to the cluster store.
sync <-chan struct{}
}

func NewController(machineID string, service *Service, store *store.Store) *Controller {
func NewController(machineID string, service *Service, store *store.Store, sync <-chan struct{}) *Controller {
return &Controller{
machineID: machineID,
client: service.Client,
service: service,
store: store,
sync: sync,
}
}

Expand Down Expand Up @@ -137,6 +140,11 @@ func (c *Controller) WatchAndSyncContainers(ctx context.Context) error {
if err := c.syncContainersToStore(ctx); err != nil {
return fmt.Errorf("sync containers to cluster store: %w", err)
}
case <-c.sync:
slog.Debug("Syncing containers to cluster store triggered by spec update.")
if err := c.syncContainersToStore(ctx); err != nil {
return fmt.Errorf("sync containers to cluster store: %w", err)
}
case err := <-errCh:
if errors.Is(err, context.Canceled) {
return nil
Expand Down
Loading