fix: lint — use presentation helpers, extract functions to reduce complexity
- commit
- 7fb924c6c58a29514f2799351825dcf9c1db83f1
- author
- brice · · 2026-03-19 05:54 UTC
- parent
- 950bfdc
- signature
- unsigned
2 files · +147 -111
M internal/adapters/in/cli/preview.go +124 -91
| @@ -1,8 +1,9 @@ | |||
| 1 | 1 | package cli | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "encoding/json" | |
| 4 | + | "context" | |
| 5 | 5 | "fmt" | |
| 6 | + | "io" | |
| 6 | 7 | "os" | |
| 7 | 8 | "os/exec" | |
| 8 | 9 | "strings" | |
| @@ -11,7 +12,7 @@ import ( | |||
| 11 | 12 | ||
| 12 | 13 | "github.com/spf13/cobra" | |
| 13 | 14 | ||
| 14 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | |
| 15 | + | "github.com/bnema/gordon/internal/domain" | |
| 15 | 16 | "github.com/bnema/gordon/internal/usecase/auto/preview" | |
| 16 | 17 | ) | |
| 17 | 18 | ||
| @@ -56,20 +57,16 @@ func newPreviewCreateCmd() *cobra.Command { | |||
| 56 | 57 | out := cmd.OutOrStdout() | |
| 57 | 58 | ||
| 58 | 59 | // Resolve preview name from arg or git branch. | |
| 59 | - | name := "" | |
| 60 | - | if len(args) > 0 { | |
| 61 | - | name = args[0] | |
| 62 | - | } else { | |
| 63 | - | branch, err := detectGitBranch() | |
| 64 | - | if err != nil { | |
| 65 | - | return fmt.Errorf("no preview name provided and could not detect git branch: %w", err) | |
| 66 | - | } | |
| 67 | - | name = preview.SanitizeBranchName(branch) | |
| 60 | + | name, err := resolvePreviewName(args) | |
| 61 | + | if err != nil { | |
| 62 | + | return err | |
| 68 | 63 | } | |
| 69 | 64 | ||
| 70 | 65 | previewTag := "preview-" + name | |
| 71 | 66 | ||
| 72 | - | fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name)) | |
| 67 | + | if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil { | |
| 68 | + | return err | |
| 69 | + | } | |
| 73 | 70 | ||
| 74 | 71 | // Resolve control plane (remote or local) — same as push. | |
| 75 | 72 | handle, err := resolveControlPlane(configPath) | |
| @@ -78,61 +75,16 @@ func newPreviewCreateCmd() *cobra.Command { | |||
| 78 | 75 | } | |
| 79 | 76 | defer handle.close() | |
| 80 | 77 | ||
| 81 | - | // Detect image name and registry using the same logic as push. | |
| 82 | - | dockerfile := "Dockerfile" | |
| 83 | - | imageName, err := detectImageName(dockerfile) | |
| 78 | + | previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag) | |
| 84 | 79 | if err != nil { | |
| 85 | 80 | return err | |
| 86 | 81 | } | |
| 87 | 82 | ||
| 88 | - | fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName)) | |
| 89 | - | ||
| 90 | - | // Resolve registry from routes. | |
| 91 | - | registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile) | |
| 92 | - | if err != nil { | |
| 93 | - | return fmt.Errorf("failed to resolve registry: %w", err) | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | // Build the image ref with the preview tag. | |
| 97 | - | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |
| 98 | - | fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef)) | |
| 99 | - | ||
| 100 | - | // Build and push. | |
| 101 | - | imageOps, err := newImageOpsFn() | |
| 102 | - | if err != nil { | |
| 83 | + | if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil { | |
| 103 | 84 | return err | |
| 104 | 85 | } | |
| 105 | 86 | ||
| 106 | - | if !noBuild { | |
| 107 | - | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |
| 108 | - | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | buildPlatform := platform | |
| 112 | - | buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef) | |
| 113 | - | fmt.Fprintln(out, "\nBuilding image...") | |
| 114 | - | if err := imageOps.Build(ctx, buildArgs); err != nil { | |
| 115 | - | return err | |
| 116 | - | } | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | fmt.Fprintln(out, "Pushing...") | |
| 120 | - | if err := imageOps.Push(ctx, previewRef); err != nil { | |
| 121 | - | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | fmt.Fprintln(out, styles.RenderSuccess("Push complete")) | |
| 125 | - | fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n", | |
| 126 | - | styles.Theme.Bold.Render(name)) | |
| 127 | - | ||
| 128 | - | if ttl != "" { | |
| 129 | - | fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl) | |
| 130 | - | } | |
| 131 | - | if noData { | |
| 132 | - | fmt.Fprintf(out, "Data copy: skipped (--no-data)\n") | |
| 133 | - | } | |
| 134 | - | ||
| 135 | - | return nil | |
| 87 | + | return printPreviewCreateSummary(out, name, ttl, noData) | |
| 136 | 88 | }, | |
| 137 | 89 | } | |
| 138 | 90 | ||
| @@ -163,38 +115,14 @@ func newPreviewListCmd() *cobra.Command { | |||
| 163 | 115 | } | |
| 164 | 116 | ||
| 165 | 117 | if jsonOutput { | |
| 166 | - | enc := json.NewEncoder(out) | |
| 167 | - | enc.SetIndent("", " ") | |
| 168 | - | return enc.Encode(previews) | |
| 118 | + | return writeJSON(out, previews) | |
| 169 | 119 | } | |
| 170 | 120 | ||
| 171 | 121 | if len(previews) == 0 { | |
| 172 | - | fmt.Fprintln(out, "No active previews") | |
| 173 | - | return nil | |
| 122 | + | return cliWriteLine(out, cliRenderEmptyState("No active previews")) | |
| 174 | 123 | } | |
| 175 | 124 | ||
| 176 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 177 | - | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |
| 178 | - | return err | |
| 179 | - | } | |
| 180 | - | now := time.Now() | |
| 181 | - | for _, p := range previews { | |
| 182 | - | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |
| 183 | - | expiresStr := remaining.String() | |
| 184 | - | if p.IsExpired(now) { | |
| 185 | - | expiresStr = "expired" | |
| 186 | - | } | |
| 187 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |
| 188 | - | p.Name, | |
| 189 | - | p.Domain, | |
| 190 | - | truncateImage(p.Image, 40), | |
| 191 | - | p.CreatedAt.Format(time.DateTime), | |
| 192 | - | expiresStr, | |
| 193 | - | ); err != nil { | |
| 194 | - | return err | |
| 195 | - | } | |
| 196 | - | } | |
| 197 | - | return w.Flush() | |
| 125 | + | return printPreviewTable(out, previews) | |
| 198 | 126 | }, | |
| 199 | 127 | } | |
| 200 | 128 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | |
| @@ -220,8 +148,7 @@ func newPreviewDeleteCmd() *cobra.Command { | |||
| 220 | 148 | return fmt.Errorf("failed to delete preview %q: %w", name, err) | |
| 221 | 149 | } | |
| 222 | 150 | ||
| 223 | - | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name))) | |
| 224 | - | return nil | |
| 151 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name))) | |
| 225 | 152 | }, | |
| 226 | 153 | } | |
| 227 | 154 | } | |
| @@ -246,14 +173,120 @@ func newPreviewExtendCmd() *cobra.Command { | |||
| 246 | 173 | return fmt.Errorf("failed to extend preview %q: %w", name, err) | |
| 247 | 174 | } | |
| 248 | 175 | ||
| 249 | - | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) | |
| 250 | - | return nil | |
| 176 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) | |
| 251 | 177 | }, | |
| 252 | 178 | } | |
| 253 | 179 | cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration") | |
| 254 | 180 | return cmd | |
| 255 | 181 | } | |
| 256 | 182 | ||
| 183 | + | func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error { | |
| 184 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |
| 185 | + | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |
| 186 | + | return err | |
| 187 | + | } | |
| 188 | + | now := time.Now() | |
| 189 | + | for _, p := range previews { | |
| 190 | + | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |
| 191 | + | expiresStr := remaining.String() | |
| 192 | + | if p.IsExpired(now) { | |
| 193 | + | expiresStr = "expired" | |
| 194 | + | } | |
| 195 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |
| 196 | + | p.Name, | |
| 197 | + | p.Domain, | |
| 198 | + | truncateImage(p.Image, 40), | |
| 199 | + | p.CreatedAt.Format(time.DateTime), | |
| 200 | + | expiresStr, | |
| 201 | + | ); err != nil { | |
| 202 | + | return err | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return w.Flush() | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | func resolvePreviewName(args []string) (string, error) { | |
| 209 | + | if len(args) > 0 { | |
| 210 | + | return args[0], nil | |
| 211 | + | } | |
| 212 | + | branch, err := detectGitBranch() | |
| 213 | + | if err != nil { | |
| 214 | + | return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err) | |
| 215 | + | } | |
| 216 | + | return preview.SanitizeBranchName(branch), nil | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) { | |
| 220 | + | dockerfile := "Dockerfile" | |
| 221 | + | imageName, err := detectImageName(dockerfile) | |
| 222 | + | if err != nil { | |
| 223 | + | return "", err | |
| 224 | + | } | |
| 225 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil { | |
| 226 | + | return "", err | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile) | |
| 230 | + | if err != nil { | |
| 231 | + | return "", fmt.Errorf("failed to resolve registry: %w", err) | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |
| 235 | + | if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil { | |
| 236 | + | return "", err | |
| 237 | + | } | |
| 238 | + | return previewRef, nil | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error { | |
| 242 | + | imageOps, err := newImageOpsFn() | |
| 243 | + | if err != nil { | |
| 244 | + | return err | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | if !noBuild { | |
| 248 | + | dockerfile := "Dockerfile" | |
| 249 | + | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |
| 250 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |
| 251 | + | } | |
| 252 | + | buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef) | |
| 253 | + | if err := cliWriteLine(out, "\nBuilding image..."); err != nil { | |
| 254 | + | return err | |
| 255 | + | } | |
| 256 | + | if err := imageOps.Build(ctx, buildArgs); err != nil { | |
| 257 | + | return err | |
| 258 | + | } | |
| 259 | + | } | |
| 260 | + | ||
| 261 | + | if err := cliWriteLine(out, "Pushing..."); err != nil { | |
| 262 | + | return err | |
| 263 | + | } | |
| 264 | + | if err := imageOps.Push(ctx, previewRef); err != nil { | |
| 265 | + | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |
| 266 | + | } | |
| 267 | + | return nil | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error { | |
| 271 | + | if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil { | |
| 272 | + | return err | |
| 273 | + | } | |
| 274 | + | if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil { | |
| 275 | + | return err | |
| 276 | + | } | |
| 277 | + | if ttl != "" { | |
| 278 | + | if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil { | |
| 279 | + | return err | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | if noData { | |
| 283 | + | if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil { | |
| 284 | + | return err | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | return nil | |
| 288 | + | } | |
| 289 | + | ||
| 257 | 290 | func detectGitBranch() (string, error) { | |
| 258 | 291 | out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | |
| 259 | 292 | if err != nil { | |
| @@ -1,8 +1,9 @@ | |||||
| 1 | package cli | 1 | package cli | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "encoding/json" | 4 | + | "context" |
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "io" | |||
| 6 | "os" | 7 | "os" | ||
| 7 | "os/exec" | 8 | "os/exec" | ||
| 8 | "strings" | 9 | "strings" | ||
| @@ -11,7 +12,7 @@ import ( | |||||
| 11 | 12 | ||||
| 12 | "github.com/spf13/cobra" | 13 | "github.com/spf13/cobra" | ||
| 13 | 14 | ||||
| 14 | - | "github.com/bnema/gordon/internal/adapters/in/cli/ui/styles" | 15 | + | "github.com/bnema/gordon/internal/domain" |
| 15 | "github.com/bnema/gordon/internal/usecase/auto/preview" | 16 | "github.com/bnema/gordon/internal/usecase/auto/preview" | ||
| 16 | ) | 17 | ) | ||
| 17 | 18 | ||||
| @@ -56,20 +57,16 @@ func newPreviewCreateCmd() *cobra.Command { | |||||
| 56 | out := cmd.OutOrStdout() | 57 | out := cmd.OutOrStdout() | ||
| 57 | 58 | ||||
| 58 | // Resolve preview name from arg or git branch. | 59 | // Resolve preview name from arg or git branch. | ||
| 59 | - | name := "" | 60 | + | name, err := resolvePreviewName(args) |
| 60 | - | if len(args) > 0 { | 61 | + | if err != nil { |
| 61 | - | name = args[0] | 62 | + | return err |
| 62 | - | } else { | |||
| 63 | - | branch, err := detectGitBranch() | |||
| 64 | - | if err != nil { | |||
| 65 | - | return fmt.Errorf("no preview name provided and could not detect git branch: %w", err) | |||
| 66 | - | } | |||
| 67 | - | name = preview.SanitizeBranchName(branch) | |||
| 68 | } | 63 | } | ||
| 69 | 64 | ||||
| 70 | previewTag := "preview-" + name | 65 | previewTag := "preview-" + name | ||
| 71 | 66 | ||||
| 72 | - | fmt.Fprintf(out, "Creating preview: %s\n", styles.Theme.Bold.Render(name)) | 67 | + | if err := cliWriteLine(out, cliRenderTitle("Preview: "+name)); err != nil { |
| 68 | + | return err | |||
| 69 | + | } | |||
| 73 | 70 | ||||
| 74 | // Resolve control plane (remote or local) — same as push. | 71 | // Resolve control plane (remote or local) — same as push. | ||
| 75 | handle, err := resolveControlPlane(configPath) | 72 | handle, err := resolveControlPlane(configPath) | ||
| @@ -78,61 +75,16 @@ func newPreviewCreateCmd() *cobra.Command { | |||||
| 78 | } | 75 | } | ||
| 79 | defer handle.close() | 76 | defer handle.close() | ||
| 80 | 77 | ||||
| 81 | - | // Detect image name and registry using the same logic as push. | 78 | + | previewRef, err := resolvePreviewImageRef(ctx, handle.plane, out, previewTag) |
| 82 | - | dockerfile := "Dockerfile" | |||
| 83 | - | imageName, err := detectImageName(dockerfile) | |||
| 84 | if err != nil { | 79 | if err != nil { | ||
| 85 | return err | 80 | return err | ||
| 86 | } | 81 | } | ||
| 87 | 82 | ||||
| 88 | - | fmt.Fprintf(out, "Image: %s\n", styles.Theme.Bold.Render(imageName)) | 83 | + | if err := buildAndPushPreview(ctx, out, previewRef, previewTag, platform, noBuild); err != nil { |
| 89 | - | ||||
| 90 | - | // Resolve registry from routes. | |||
| 91 | - | registry, _, _, err := resolveRoute(ctx, handle.plane, "", "", dockerfile) | |||
| 92 | - | if err != nil { | |||
| 93 | - | return fmt.Errorf("failed to resolve registry: %w", err) | |||
| 94 | - | } | |||
| 95 | - | ||||
| 96 | - | // Build the image ref with the preview tag. | |||
| 97 | - | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |||
| 98 | - | fmt.Fprintf(out, "Tag: %s\n", styles.Theme.Bold.Render(previewRef)) | |||
| 99 | - | ||||
| 100 | - | // Build and push. | |||
| 101 | - | imageOps, err := newImageOpsFn() | |||
| 102 | - | if err != nil { | |||
| 103 | return err | 84 | return err | ||
| 104 | } | 85 | } | ||
| 105 | 86 | ||||
| 106 | - | if !noBuild { | 87 | + | return printPreviewCreateSummary(out, name, ttl, noData) |
| 107 | - | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |||
| 108 | - | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |||
| 109 | - | } | |||
| 110 | - | ||||
| 111 | - | buildPlatform := platform | |||
| 112 | - | buildArgs := buildImageArgs(ctx, previewTag, buildPlatform, dockerfile, nil, previewRef, previewRef) | |||
| 113 | - | fmt.Fprintln(out, "\nBuilding image...") | |||
| 114 | - | if err := imageOps.Build(ctx, buildArgs); err != nil { | |||
| 115 | - | return err | |||
| 116 | - | } | |||
| 117 | - | } | |||
| 118 | - | ||||
| 119 | - | fmt.Fprintln(out, "Pushing...") | |||
| 120 | - | if err := imageOps.Push(ctx, previewRef); err != nil { | |||
| 121 | - | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |||
| 122 | - | } | |||
| 123 | - | ||||
| 124 | - | fmt.Fprintln(out, styles.RenderSuccess("Push complete")) | |||
| 125 | - | fmt.Fprintf(out, "Preview %s will be created by the server's autopreview handler.\n", | |||
| 126 | - | styles.Theme.Bold.Render(name)) | |||
| 127 | - | ||||
| 128 | - | if ttl != "" { | |||
| 129 | - | fmt.Fprintf(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl) | |||
| 130 | - | } | |||
| 131 | - | if noData { | |||
| 132 | - | fmt.Fprintf(out, "Data copy: skipped (--no-data)\n") | |||
| 133 | - | } | |||
| 134 | - | ||||
| 135 | - | return nil | |||
| 136 | }, | 88 | }, | ||
| 137 | } | 89 | } | ||
| 138 | 90 | ||||
| @@ -163,38 +115,14 @@ func newPreviewListCmd() *cobra.Command { | |||||
| 163 | } | 115 | } | ||
| 164 | 116 | ||||
| 165 | if jsonOutput { | 117 | if jsonOutput { | ||
| 166 | - | enc := json.NewEncoder(out) | 118 | + | return writeJSON(out, previews) |
| 167 | - | enc.SetIndent("", " ") | |||
| 168 | - | return enc.Encode(previews) | |||
| 169 | } | 119 | } | ||
| 170 | 120 | ||||
| 171 | if len(previews) == 0 { | 121 | if len(previews) == 0 { | ||
| 172 | - | fmt.Fprintln(out, "No active previews") | 122 | + | return cliWriteLine(out, cliRenderEmptyState("No active previews")) |
| 173 | - | return nil | |||
| 174 | } | 123 | } | ||
| 175 | 124 | ||||
| 176 | - | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | 125 | + | return printPreviewTable(out, previews) |
| 177 | - | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |||
| 178 | - | return err | |||
| 179 | - | } | |||
| 180 | - | now := time.Now() | |||
| 181 | - | for _, p := range previews { | |||
| 182 | - | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |||
| 183 | - | expiresStr := remaining.String() | |||
| 184 | - | if p.IsExpired(now) { | |||
| 185 | - | expiresStr = "expired" | |||
| 186 | - | } | |||
| 187 | - | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |||
| 188 | - | p.Name, | |||
| 189 | - | p.Domain, | |||
| 190 | - | truncateImage(p.Image, 40), | |||
| 191 | - | p.CreatedAt.Format(time.DateTime), | |||
| 192 | - | expiresStr, | |||
| 193 | - | ); err != nil { | |||
| 194 | - | return err | |||
| 195 | - | } | |||
| 196 | - | } | |||
| 197 | - | return w.Flush() | |||
| 198 | }, | 126 | }, | ||
| 199 | } | 127 | } | ||
| 200 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | 128 | cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output as JSON") | ||
| @@ -220,8 +148,7 @@ func newPreviewDeleteCmd() *cobra.Command { | |||||
| 220 | return fmt.Errorf("failed to delete preview %q: %w", name, err) | 148 | return fmt.Errorf("failed to delete preview %q: %w", name, err) | ||
| 221 | } | 149 | } | ||
| 222 | 150 | ||||
| 223 | - | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q deleted", name))) | 151 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q deleted", name))) |
| 224 | - | return nil | |||
| 225 | }, | 152 | }, | ||
| 226 | } | 153 | } | ||
| 227 | } | 154 | } | ||
| @@ -246,14 +173,120 @@ func newPreviewExtendCmd() *cobra.Command { | |||||
| 246 | return fmt.Errorf("failed to extend preview %q: %w", name, err) | 173 | return fmt.Errorf("failed to extend preview %q: %w", name, err) | ||
| 247 | } | 174 | } | ||
| 248 | 175 | ||||
| 249 | - | fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) | 176 | + | return cliWriteLine(out, cliRenderSuccess(fmt.Sprintf("Preview %q extended by %s", name, ttl))) |
| 250 | - | return nil | |||
| 251 | }, | 177 | }, | ||
| 252 | } | 178 | } | ||
| 253 | cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration") | 179 | cmd.Flags().StringVar(&ttl, "ttl", "24h", "Additional TTL duration") | ||
| 254 | return cmd | 180 | return cmd | ||
| 255 | } | 181 | } | ||
| 256 | 182 | ||||
| 183 | + | func printPreviewTable(out io.Writer, previews []domain.PreviewRoute) error { | |||
| 184 | + | w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0) | |||
| 185 | + | if _, err := fmt.Fprintln(w, "NAME\tDOMAIN\tIMAGE\tCREATED\tEXPIRES"); err != nil { | |||
| 186 | + | return err | |||
| 187 | + | } | |||
| 188 | + | now := time.Now() | |||
| 189 | + | for _, p := range previews { | |||
| 190 | + | remaining := time.Until(p.ExpiresAt).Truncate(time.Minute) | |||
| 191 | + | expiresStr := remaining.String() | |||
| 192 | + | if p.IsExpired(now) { | |||
| 193 | + | expiresStr = "expired" | |||
| 194 | + | } | |||
| 195 | + | if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", | |||
| 196 | + | p.Name, | |||
| 197 | + | p.Domain, | |||
| 198 | + | truncateImage(p.Image, 40), | |||
| 199 | + | p.CreatedAt.Format(time.DateTime), | |||
| 200 | + | expiresStr, | |||
| 201 | + | ); err != nil { | |||
| 202 | + | return err | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return w.Flush() | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func resolvePreviewName(args []string) (string, error) { | |||
| 209 | + | if len(args) > 0 { | |||
| 210 | + | return args[0], nil | |||
| 211 | + | } | |||
| 212 | + | branch, err := detectGitBranch() | |||
| 213 | + | if err != nil { | |||
| 214 | + | return "", fmt.Errorf("no preview name provided and could not detect git branch: %w", err) | |||
| 215 | + | } | |||
| 216 | + | return preview.SanitizeBranchName(branch), nil | |||
| 217 | + | } | |||
| 218 | + | ||||
| 219 | + | func resolvePreviewImageRef(ctx context.Context, cp ControlPlane, out io.Writer, previewTag string) (string, error) { | |||
| 220 | + | dockerfile := "Dockerfile" | |||
| 221 | + | imageName, err := detectImageName(dockerfile) | |||
| 222 | + | if err != nil { | |||
| 223 | + | return "", err | |||
| 224 | + | } | |||
| 225 | + | if err := cliWriteLine(out, cliRenderMeta("Image:", imageName)); err != nil { | |||
| 226 | + | return "", err | |||
| 227 | + | } | |||
| 228 | + | ||||
| 229 | + | registry, _, _, err := resolveRoute(ctx, cp, "", "", dockerfile) | |||
| 230 | + | if err != nil { | |||
| 231 | + | return "", fmt.Errorf("failed to resolve registry: %w", err) | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | previewRef := fmt.Sprintf("%s/%s:%s", registry, imageName, previewTag) | |||
| 235 | + | if err := cliWriteLine(out, cliRenderMeta("Tag:", previewRef)); err != nil { | |||
| 236 | + | return "", err | |||
| 237 | + | } | |||
| 238 | + | return previewRef, nil | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func buildAndPushPreview(ctx context.Context, out io.Writer, previewRef, previewTag, platform string, noBuild bool) error { | |||
| 242 | + | imageOps, err := newImageOpsFn() | |||
| 243 | + | if err != nil { | |||
| 244 | + | return err | |||
| 245 | + | } | |||
| 246 | + | ||||
| 247 | + | if !noBuild { | |||
| 248 | + | dockerfile := "Dockerfile" | |||
| 249 | + | if _, statErr := os.Stat(dockerfile); os.IsNotExist(statErr) { | |||
| 250 | + | return fmt.Errorf("dockerfile not found: %s", dockerfile) | |||
| 251 | + | } | |||
| 252 | + | buildArgs := buildImageArgs(ctx, previewTag, platform, dockerfile, nil, previewRef, previewRef) | |||
| 253 | + | if err := cliWriteLine(out, "\nBuilding image..."); err != nil { | |||
| 254 | + | return err | |||
| 255 | + | } | |||
| 256 | + | if err := imageOps.Build(ctx, buildArgs); err != nil { | |||
| 257 | + | return err | |||
| 258 | + | } | |||
| 259 | + | } | |||
| 260 | + | ||||
| 261 | + | if err := cliWriteLine(out, "Pushing..."); err != nil { | |||
| 262 | + | return err | |||
| 263 | + | } | |||
| 264 | + | if err := imageOps.Push(ctx, previewRef); err != nil { | |||
| 265 | + | return fmt.Errorf("failed to push %s: %w", previewRef, err) | |||
| 266 | + | } | |||
| 267 | + | return nil | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func printPreviewCreateSummary(out io.Writer, name, ttl string, noData bool) error { | |||
| 271 | + | if err := cliWriteLine(out, cliRenderSuccess("Push complete")); err != nil { | |||
| 272 | + | return err | |||
| 273 | + | } | |||
| 274 | + | if err := cliWritef(out, "Preview %s will be created by the server's autopreview handler.\n", name); err != nil { | |||
| 275 | + | return err | |||
| 276 | + | } | |||
| 277 | + | if ttl != "" { | |||
| 278 | + | if err := cliWritef(out, "Requested TTL: %s (server config controls actual TTL)\n", ttl); err != nil { | |||
| 279 | + | return err | |||
| 280 | + | } | |||
| 281 | + | } | |||
| 282 | + | if noData { | |||
| 283 | + | if err := cliWriteLine(out, cliRenderInfo("Data copy: skipped (--no-data)")); err != nil { | |||
| 284 | + | return err | |||
| 285 | + | } | |||
| 286 | + | } | |||
| 287 | + | return nil | |||
| 288 | + | } | |||
| 289 | + | ||||
| 257 | func detectGitBranch() (string, error) { | 290 | func detectGitBranch() (string, error) { | ||
| 258 | out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | 291 | out, err := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD").Output() // #nosec G204 | ||
| 259 | if err != nil { | 292 | if err != nil { | ||
M internal/app/run.go +23 -20
| @@ -545,26 +545,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 545 | 545 | // Create log service for accessing logs via admin API | |
| 546 | 546 | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | |
| 547 | 547 | ||
| 548 | - | // Preview store and service | |
| 549 | - | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | |
| 550 | - | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | |
| 551 | - | previewConfig := svc.configSvc.GetPreviewConfig() | |
| 552 | - | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | |
| 553 | - | if err := svc.previewService.Load(ctx); err != nil { | |
| 554 | - | log.Warn().Err(err).Msg("failed to load previews") | |
| 555 | - | } | |
| 556 | - | ||
| 557 | - | // Start TTL ticker to expire and clean up preview environments | |
| 558 | - | svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) { | |
| 559 | - | for _, containerName := range p.Containers { | |
| 560 | - | _ = svc.runtime.StopContainer(ctx, containerName) | |
| 561 | - | _ = svc.runtime.RemoveContainer(ctx, containerName, true) | |
| 562 | - | } | |
| 563 | - | for _, volName := range p.Volumes { | |
| 564 | - | _ = svc.runtime.RemoveVolume(ctx, volName, true) | |
| 565 | - | } | |
| 566 | - | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | |
| 567 | - | }) | |
| 548 | + | initPreviewService(ctx, cfg, svc, log) | |
| 568 | 549 | ||
| 569 | 550 | // Create admin handler for admin API | |
| 570 | 551 | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| @@ -586,6 +567,28 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||
| 586 | 567 | return svc, nil | |
| 587 | 568 | } | |
| 588 | 569 | ||
| 570 | + | // initPreviewService sets up the preview store, service, and TTL ticker. | |
| 571 | + | func initPreviewService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) { | |
| 572 | + | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | |
| 573 | + | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | |
| 574 | + | previewConfig := svc.configSvc.GetPreviewConfig() | |
| 575 | + | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | |
| 576 | + | if err := svc.previewService.Load(ctx); err != nil { | |
| 577 | + | log.Warn().Err(err).Msg("failed to load previews") | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) { | |
| 581 | + | for _, containerName := range p.Containers { | |
| 582 | + | _ = svc.runtime.StopContainer(ctx, containerName) | |
| 583 | + | _ = svc.runtime.RemoveContainer(ctx, containerName, true) | |
| 584 | + | } | |
| 585 | + | for _, volName := range p.Volumes { | |
| 586 | + | _ = svc.runtime.RemoveVolume(ctx, volName, true) | |
| 587 | + | } | |
| 588 | + | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | |
| 589 | + | }) | |
| 590 | + | } | |
| 591 | + | ||
| 589 | 592 | // injectTelemetryMetrics creates and injects OTel metrics into services when | |
| 590 | 593 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | |
| 591 | 594 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | |
| @@ -545,26 +545,7 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 545 | // Create log service for accessing logs via admin API | 545 | // Create log service for accessing logs via admin API | ||
| 546 | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | 546 | svc.logSvc = logs.NewService(resolveLogFilePath(cfg), cfg.Logging.File.Enabled, svc.containerSvc, svc.runtime, log) | ||
| 547 | 547 | ||||
| 548 | - | // Preview store and service | 548 | + | initPreviewService(ctx, cfg, svc, log) |
| 549 | - | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | |||
| 550 | - | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | |||
| 551 | - | previewConfig := svc.configSvc.GetPreviewConfig() | |||
| 552 | - | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | |||
| 553 | - | if err := svc.previewService.Load(ctx); err != nil { | |||
| 554 | - | log.Warn().Err(err).Msg("failed to load previews") | |||
| 555 | - | } | |||
| 556 | - | ||||
| 557 | - | // Start TTL ticker to expire and clean up preview environments | |||
| 558 | - | svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) { | |||
| 559 | - | for _, containerName := range p.Containers { | |||
| 560 | - | _ = svc.runtime.StopContainer(ctx, containerName) | |||
| 561 | - | _ = svc.runtime.RemoveContainer(ctx, containerName, true) | |||
| 562 | - | } | |||
| 563 | - | for _, volName := range p.Volumes { | |||
| 564 | - | _ = svc.runtime.RemoveVolume(ctx, volName, true) | |||
| 565 | - | } | |||
| 566 | - | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | |||
| 567 | - | }) | |||
| 568 | 549 | ||||
| 569 | // Create admin handler for admin API | 550 | // Create admin handler for admin API | ||
| 570 | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | 551 | svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | ||
| @@ -586,6 +567,28 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra | |||||
| 586 | return svc, nil | 567 | return svc, nil | ||
| 587 | } | 568 | } | ||
| 588 | 569 | ||||
| 570 | + | // initPreviewService sets up the preview store, service, and TTL ticker. | |||
| 571 | + | func initPreviewService(ctx context.Context, cfg Config, svc *services, log zerowrap.Logger) { | |||
| 572 | + | previewStorePath := filepath.Join(resolveDataDir(cfg.Server.DataDir), "previews.json") | |||
| 573 | + | svc.previewStore = filesystem.NewPreviewStore(previewStorePath) | |||
| 574 | + | previewConfig := svc.configSvc.GetPreviewConfig() | |||
| 575 | + | svc.previewService = preview.NewService(svc.previewStore, previewConfig.TTL) | |||
| 576 | + | if err := svc.previewService.Load(ctx); err != nil { | |||
| 577 | + | log.Warn().Err(err).Msg("failed to load previews") | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | svc.previewService.StartTicker(ctx, 60*time.Second, func(ctx context.Context, p domain.PreviewRoute) { | |||
| 581 | + | for _, containerName := range p.Containers { | |||
| 582 | + | _ = svc.runtime.StopContainer(ctx, containerName) | |||
| 583 | + | _ = svc.runtime.RemoveContainer(ctx, containerName, true) | |||
| 584 | + | } | |||
| 585 | + | for _, volName := range p.Volumes { | |||
| 586 | + | _ = svc.runtime.RemoveVolume(ctx, volName, true) | |||
| 587 | + | } | |||
| 588 | + | svc.proxySvc.InvalidateTarget(ctx, p.Domain) | |||
| 589 | + | }) | |||
| 590 | + | } | |||
| 591 | + | ||||
| 589 | // injectTelemetryMetrics creates and injects OTel metrics into services when | 592 | // injectTelemetryMetrics creates and injects OTel metrics into services when | ||
| 590 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | 593 | // telemetry is enabled. Skipped otherwise to avoid unnecessary allocations. | ||
| 591 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | 594 | func injectTelemetryMetrics(cfg Config, svc *services, log zerowrap.Logger) { | ||