From 09bea1d12d0701deaf119533130017789447ffa9 Mon Sep 17 00:00:00 2001 From: Pavel Sviderski Date: Wed, 26 Feb 2025 14:23:00 +1000 Subject: [PATCH] feat(dns): update ingress records in Uncloud DNS when deploying Caddy --- cmd/uncloud/caddy/deploy.go | 35 ++++++++-- internal/cli/client/dns.go | 131 +++++++++++++++++++++++++++++++++--- test/e2e/service_test.go | 2 + 3 files changed, 152 insertions(+), 16 deletions(-) diff --git a/cmd/uncloud/caddy/deploy.go b/cmd/uncloud/caddy/deploy.go index bf73a062..a5505a94 100644 --- a/cmd/uncloud/caddy/deploy.go +++ b/cmd/uncloud/caddy/deploy.go @@ -183,17 +183,38 @@ func deploy(ctx context.Context, uncli *cli.CLI, opts deployOptions) error { return fmt.Errorf("get cluster domain: %w", err) } - fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy containers...") - records, err := clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName) + fmt.Println("Updating cluster domain records in Uncloud DNS to point to machines running caddy service...") + // TODO: split the method into two: one to get the records and one to update them to ask for update confirmation. + + var records []*pb.DNSRecord + err = progress.RunWithTitle(ctx, func(ctx context.Context) error { + records, err = clusterClient.CreateIngressRecords(ctx, client.CaddyServiceName) + return err + }, uncli.ProgressOut(), "Verifying internet access to caddy service") if err != nil { - return fmt.Errorf("update ingress records: %w", err) + if errors.Is(err, client.ErrNoReachableMachines) { + fmt.Println() + fmt.Println("DNS records could not be updated as there are no internet-reachable machines running " + + "caddy containers.") + fmt.Println() + fmt.Println("Possible solutions:") + fmt.Println("- Ensure your machines have public IP addresses") + fmt.Println("- Use --public-ip flag when adding machines to override the automatically detected IPs") + fmt.Println("- Check firewall settings on your machines") + fmt.Println("- Configure port forwarding if behind NAT") + fmt.Println("- Retry Caddy deployment after resolving connectivity issues with 'uc caddy deploy'") + fmt.Println() + fmt.Println("Your services will not be accessible from the internet until at least one machine " + + "becomes reachable.") + } + return fmt.Errorf("failed to update DNS records pointing to caddy service: %w", err) } - fmt.Println("DNS records updated successfully:") - for _, r := range records { - fmt.Printf(" %s %s -> %s", r.Name, r.Type, strings.Join(r.Values, ", ")) - } fmt.Println() + fmt.Println("DNS records updated to use only the internet-reachable machines running caddy service:") + for _, r := range records { + fmt.Printf(" %s %s -> %s\n", r.Name, r.Type, strings.Join(r.Values, ", ")) + } return nil } diff --git a/internal/cli/client/dns.go b/internal/cli/client/dns.go index b13c6dee..509a1207 100644 --- a/internal/cli/client/dns.go +++ b/internal/cli/client/dns.go @@ -2,24 +2,128 @@ package client import ( "context" + "errors" "fmt" + "github.com/docker/compose/v2/pkg/progress" + "io" + "net/http" + "sync" + "time" "uncloud/internal/machine/api/pb" + "uncloud/internal/machine/caddyfile" ) -// TODO: +var ErrNoReachableMachines = errors.New("no internet-reachable machines running service containers") + +// CreateIngressRecords verifies which machines running the specified service (typically Caddy) are reachable from +// the internet, then creates DNS records for the cluster domain pointing to those machines. It tests each machine +// by sending HTTP requests to their public IPs. Only machines that respond correctly with their machine ID are included +// in the resulting DNS configuration. Returns the created DNS records or an error. func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) ([]*pb.DNSRecord, error) { - // TODO: - // - Inspect the service and get the list of machines it runs on. - // - For each machine get the machine's public IP address(s). - // - Update the wildcard DNS record for the service with the public IP addresses (call Cluster API). + svc, err := cli.InspectService(ctx, serviceID) + if err != nil { + return nil, fmt.Errorf("inspect service '%s': %w", serviceID, err) + } + + machineIDs := make(map[string]struct{}, len(svc.Containers)) + for _, mc := range svc.Containers { + machineIDs[mc.MachineID] = struct{}{} + } + + var wg sync.WaitGroup + reachableMachines := make(chan *pb.MachineInfo) + + for id := range machineIDs { + m, err := cli.InspectMachine(ctx, id) + if err != nil { + return nil, fmt.Errorf("inspect machine '%s': %w", id, err) + } + + if m.Machine.PublicIp == nil { + continue + } + + wg.Add(1) + go func() { + defer wg.Done() + + // Verify that the Caddy container is reachable on the machine by its public IP. + publicIP, _ := m.Machine.PublicIp.ToAddr() + + pw := progress.ContextWriter(ctx) + eventID := fmt.Sprintf("Machine %s (%s)", m.Machine.Name, publicIP) + pw.Event(progress.NewEvent(eventID, progress.Working, "Querying")) + + verifyURL := fmt.Sprintf("http://%s%s", publicIP, caddyfile.VerifyPath) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, verifyURL, nil) + if err != nil { + pw.Event(progress.NewEvent(eventID, progress.Error, err.Error())) + return + } + + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Do(req) + if err != nil { + e := unreachable(eventID) + e.Text = fmt.Sprintf("Failed to send HTTP request: %v", err) + pw.Event(e) + return + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + e := unreachable(eventID) + e.Text = fmt.Sprintf("Unexpected HTTP response status code: %d", resp.StatusCode) + pw.Event(e) + return + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + e := unreachable(eventID) + e.Text = fmt.Sprintf("Failed to read HTTP response body: %v", err) + pw.Event(e) + return + } + + // Check the response body is the machine ID to ensure the correct Caddy container is responding. + if string(body) == m.Machine.Id { + pw.Event(progress.NewEvent(eventID, progress.Done, "Reachable")) + reachableMachines <- m.Machine + } else { + bodyStr := string(body) + if len(bodyStr) > 50 { + bodyStr = bodyStr[:50] + "..." + } + + e := unreachable(eventID) + e.Text = fmt.Sprintf("Unexpected HTTP response body: %s", bodyStr) + pw.Event(e) + } + }() + } + + go func() { + wg.Wait() + close(reachableMachines) + }() + + var ingressIPs []string + for m := range reachableMachines { + ip, _ := m.PublicIp.ToAddr() + ingressIPs = append(ingressIPs, ip.String()) + } + if len(ingressIPs) == 0 { + return nil, ErrNoReachableMachines + } req := &pb.CreateDomainRecordsRequest{ Records: []*pb.DNSRecord{ { - Name: "*", - Type: pb.DNSRecord_A, - // TODO: Get the public IP addresses of the machines running Caddy containers. - Values: []string{"1.2.3.4", "5.6.7.8"}, + Name: "*", + Type: pb.DNSRecord_A, + Values: ingressIPs, }, // TODO: Add AAAA record with routable IPv6 addresses of machines running Caddy containers. }, @@ -31,3 +135,12 @@ func (cli *Client) CreateIngressRecords(ctx context.Context, serviceID string) ( return resp.Records, nil } + +// unreachable creates a new Unreachable error event. +func unreachable(id string) progress.Event { + return progress.NewEvent( + id, + progress.Error, + "Unreachable (probably behind NAT or firewall)", + ) +} diff --git a/test/e2e/service_test.go b/test/e2e/service_test.go index 0eaf76a2..c69e3911 100644 --- a/test/e2e/service_test.go +++ b/test/e2e/service_test.go @@ -403,6 +403,8 @@ func TestDeployment(t *testing.T) { require.NoError(t, err) assert.Equal(t, c.Machines[2].Name, machine2.Machine.Name) }) + + // TODO: test deployments with unreachable machines. See https://github.com/psviderski/uncloud/issues/29. } func TestRunService(t *testing.T) {