diff --git a/cmd/cli/cmd/deploy.go b/cmd/cli/cmd/deploy.go index ce1b522..2b29378 100644 --- a/cmd/cli/cmd/deploy.go +++ b/cmd/cli/cmd/deploy.go @@ -1,16 +1,24 @@ package cmd import ( + "bufio" + "encoding/json" "faas-engine-go/internal/buildcontext" "fmt" "log" "log/slog" + "net/http" + "os" "path/filepath" + "strings" "github.com/fatih/color" "github.com/spf13/cobra" ) +// Deploy flags +var forceRedeploy bool + // deployCmd represents the deploy command var deployCmd = &cobra.Command{ Use: "deploy ", @@ -42,10 +50,39 @@ to quickly create a Cobra application.`, return fmt.Errorf("failed to print success message: %w", err) } + // Check if function already exists and ask for confirmation if not using --force + fmt.Print("[2/3] Checking if function exists...") + functionExists, err := checkFunctionExists(functionName) + if err != nil { + color.Red(" Failed. \n\n%s\n", err.Error()) + return fmt.Errorf("failed to check if function exists: %w", err) + } + + if functionExists && !forceRedeploy { + if _, err := color.New(color.FgYellow).Println(" Found."); err != nil { + return fmt.Errorf("failed to print status message: %w", err) + } + + // Ask for confirmation + if !confirmReplacement(functionName) { + color.Yellow("✗ Deployment cancelled") + return nil + } + } else if functionExists { + if _, err := color.New(color.FgYellow).Println(" Found (--force enabled)."); err != nil { + return fmt.Errorf("failed to print status message: %w", err) + } + } else { + if _, err := color.New(color.FgGreen).Println(" Not found."); err != nil { + return fmt.Errorf("failed to print status message: %w", err) + } + } + //send the tarstream to the server url := fmt.Sprintf("%s/functions", serverAddr) // Stream deploy logs from server + fmt.Print("[3/3] Deploying function...") err = buildcontext.SendTarStream(tarstream, url, functionName) if err != nil { slog.Error("deployment failed", "error", err) @@ -61,6 +98,7 @@ func init() { deployCmd.Flags().StringVar(&functionName, "name", "", "Name of the function to deploy") deployCmd.Flags().StringVar(&runtimeName, "runtime", "", "Name of the runtime to use") + deployCmd.Flags().BoolVar(&forceRedeploy, "force", false, "Force redeploy without confirmation if function already exists") if err := deployCmd.MarkFlagRequired("name"); err != nil { log.Fatalf("failed to mark flag as required: %v", err) @@ -69,3 +107,45 @@ func init() { log.Fatalf("failed to mark flag as required: %v", err) } } + +// checkFunctionExists queries the runtime-manager to see if a function with this name exists +func checkFunctionExists(functionName string) (bool, error) { + url := fmt.Sprintf("%s/functions", serverAddr) + + resp, err := http.Get(url) + if err != nil { + return false, fmt.Errorf("unable to reach runtime manager at %s: %w", serverAddr, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return false, fmt.Errorf("failed to list functions: server returned %s", resp.Status) + } + + var response struct { + Functions []struct { + Name string `json:"name"` + } `json:"functions"` + } + + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return false, fmt.Errorf("failed to parse functions list: %w", err) + } + + for _, fn := range response.Functions { + if fn.Name == functionName { + return true, nil + } + } + + return false, nil +} + +// confirmReplacement prompts the user to confirm redeploying an existing function +func confirmReplacement(functionName string) bool { + fmt.Print(color.YellowString(fmt.Sprintf("Function '%s' already exists. Replace it? (yes/no): ", functionName))) + reader := bufio.NewReader(os.Stdin) + response, _ := reader.ReadString('\n') + response = strings.TrimSpace(strings.ToLower(response)) + return response == "yes" || response == "y" +} diff --git a/internal/api/deploy.go b/internal/api/deploy.go index c956be6..c08f96a 100644 --- a/internal/api/deploy.go +++ b/internal/api/deploy.go @@ -10,6 +10,7 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" "strings" "time" @@ -124,6 +125,18 @@ func DeployHandler(deployer Deployer, fs FunctionStore) http.HandlerFunc { slog.Error("failed to deactivate old versions", "error", err) } + host, _, err := net.SplitHostPort(r.Host) + if err != nil { + host = r.Host + } + + var endpoint string + if host == "localhost" || host == "127.0.0.1" { + endpoint = fmt.Sprintf("%s.localhost", functionName) + } else { + endpoint = fmt.Sprintf("%s.%s.nip.io", functionName, host) + } + fn := &models.Function{ Name: functionName, Version: functionVersion, @@ -131,7 +144,7 @@ func DeployHandler(deployer Deployer, fs FunctionStore) http.HandlerFunc { Image: config.ImageRef(config.FunctionsRepo, functionName, functionVersion), Runtime: "node", ScheduleCron: "", - Endpoint: fmt.Sprintf("%s.localhost", functionName), + Endpoint: endpoint, Status: "active", CreatedAt: time.Now(), } @@ -142,7 +155,7 @@ func DeployHandler(deployer Deployer, fs FunctionStore) http.HandlerFunc { fmt.Fprintf(out, "\nWARNING: function deployed but DB insert failed\n") } - _, _ = fmt.Fprintf(out, "\nYour function is live at: http://%s.localhost\n\n", nameParam) + _, _ = fmt.Fprintf(out, "\nYour function is live at: http://%s\n\n", endpoint) w.Header().Set("X-Deploy-Status", "OK") } } diff --git a/internal/api/deploy_test.go b/internal/api/deploy_test.go index aa416be..963f185 100644 --- a/internal/api/deploy_test.go +++ b/internal/api/deploy_test.go @@ -87,6 +87,7 @@ func TestDeployHandler_Success(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/functions", body) req.Header.Set("Content-Type", writer.FormDataContentType()) + req.Host = "localhost" rr := httptest.NewRecorder() diff --git a/internal/sdk/image.go b/internal/sdk/image.go index 544a993..c6316c5 100644 --- a/internal/sdk/image.go +++ b/internal/sdk/image.go @@ -79,7 +79,9 @@ func (d *DockerClient) BuildImage( _, err = d.cli.ImagePrune(ctx, client.ImagePruneOptions{}) if err != nil { - return err + if !strings.Contains(err.Error(), "prune operation is already running") { + return err + } } return nil diff --git a/internal/sdk/image_test.go b/internal/sdk/image_test.go index 6520d9e..8bfb36b 100644 --- a/internal/sdk/image_test.go +++ b/internal/sdk/image_test.go @@ -135,30 +135,40 @@ func TestBuildImage_InvalidDirectory(t *testing.T) { func TestBuildImage_duplicateImageName(t *testing.T) { t.Parallel() - err := testDocker.PullImage(testCtx, "alpine") - if err != nil { - t.Fatalf("unexpected error pulling alpine image: %v", err) - } - t.Log("Pulled image successfully") + imageName := "test-rebuild-image:latest" defer func() { - err := testDocker.RemoveImage(testCtx, "alpine:latest") + err := testDocker.RemoveImage(testCtx, imageName) if err != nil { t.Logf("failed to remove image: %v", err) } }() - tarstream, err := buildcontext.CreateTarStream("../../samples/hello", "node") + tarstream1, err := buildcontext.CreateTarStream("../../samples/hello", "node") if err != nil { t.Skipf("unexpected error - failed to create Tar stream: %v", err) } - err = testDocker.BuildImage(testCtx, "alpine", tarstream, io.Discard) - if err == nil { - t.Fatal("expected error for duplicate image name, got nil") + // First build + err = testDocker.BuildImage(testCtx, imageName, tarstream1, io.Discard) + if err != nil { + t.Fatalf("expected first build to succeed, got error: %v", err) + } + t.Log("first build succeeded") + + // Second build with same image name - simulates redeployment + // BuildImage with ForceRemove: true should handle this gracefully + tarstream2, err := buildcontext.CreateTarStream("../../samples/hello", "node") + if err != nil { + t.Skipf("unexpected error - failed to create Tar stream: %v", err) + } + + err = testDocker.BuildImage(testCtx, imageName, tarstream2, io.Discard) + if err != nil { + t.Fatalf("expected rebuild with same image name to succeed, got error: %v", err) } - t.Logf("received expected error: %v", err) + t.Log("successfully rebuilt image with same name") } func TestTagImage_Success(t *testing.T) { diff --git a/samples/hello/hello b/samples/hello/hello deleted file mode 100644 index 4c307c6..0000000 Binary files a/samples/hello/hello and /dev/null differ