diff --git a/.dockerignore b/.dockerignore index de5ff91..5b2701b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,11 +1,8 @@ -.git +# .git is sent so that `make build` in the Dockerfile can stamp the commit into +# upaas. List no tracked file here: git would see it as deleted in the build and +# the version would end in -dirty. .env bin/ -.editorconfig .vscode/ .idea/ *.test -LICENSE -CONVENTIONS.md -REPO_POLICIES.md -README.md diff --git a/Dockerfile b/Dockerfile index 1a996e8..572ef99 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,6 +31,7 @@ RUN go mod download COPY . . RUN make test +# Takes the version from `git describe` on the .git copied in above. RUN make build # Runtime stage diff --git a/README.md b/README.md index c7bce0f..3cc777e 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,10 @@ This recipe serves plain HTTP, so `UPAAS_PLAINTEXT_HTTP=true` is required for setup and every other form to pass the CSRF origin check. Behind a TLS-terminating reverse proxy, drop that line. +The image shows the commit it was built from (the `git describe` output) in the +page footer, the startup log and `/health`. Build it from a git clone: without +the `.git` directory it shows `dev`. + ### Deploying with Docker Compose [`docker-compose.yml`](docker-compose.yml) builds the image from this repo and @@ -241,7 +245,9 @@ Other settings from [Configuration](#configuration) go in the same file, except settings to 8080 and `UPAAS_DATA_DIR` to `/var/lib/upaas`, overriding `.env`, to match its port mapping, healthcheck and data directory mount. Then run `docker compose up -d` from the repo root; `docker compose ps` shows the -container as healthy once `/health` answers. +container as healthy once `/health` answers. To update, run `git pull` and then +`docker compose up -d --build`: without `--build`, Compose keeps running the +image built from the old checkout. **Important**: `HOST_DATA_DIR` **must** be an **absolute path** on the host. It is bind-mounted into the container and passed as `UPAAS_HOST_DATA_DIR` so that @@ -257,6 +263,11 @@ Docker's build cache rather than as untagged images. Docker Engine 28.2 and later keeps that cache under a size limit by default; on older engines, set `"builder": {"gc": {"enabled": true}}` in the host's `daemon.json`. +Building with BuildKit needs Docker Engine 18.09 or later; on an older engine, +upaas fails the deploy instead of building. A Dockerfile that uses +`RUN --network` needs Docker Engine 23.0 or later unless its `# syntax=` line +names Dockerfile frontend 1.3 or later, such as `docker/dockerfile:1`. + Session secrets are automatically generated on first startup and persisted to `$UPAAS_DATA_DIR/session.key`. diff --git a/TODO.md b/TODO.md index ff43640..7684cdc 100644 --- a/TODO.md +++ b/TODO.md @@ -20,6 +20,30 @@ regress. # Completed Steps +- 2026-09-29: The app page is 50% wider on large screens (84rem instead of + 56rem), its build log and container log boxes are twice as tall, the build log + sits between the webhook URL and the environment variables, and the container + log sits above the deploy key (#246). + +- 2026-09-29: A failed build now fails the deploy with the build's own error + instead of a later "failed to inspect image", and the deployment log shows the + end of the build output before that error. upaas refuses to build on a Docker + Engine older than 18.09, which cannot build with BuildKit. The README's + Compose section says to update with `docker compose up -d --build` and names + the Docker Engine versions builds need (#234). + +- 2026-09-29: An image built from the `Dockerfile` now shows the commit it was + built from (the `git describe` output) in the footer and `/health` instead of + `dev`: `.dockerignore` no longer leaves out `.git`, nor any tracked file, + which git would count as deleted and mark `-dirty`. upaas now also logs its + version at startup; the logger's `Identify()` was never called (#236). + +- 2026-09-29: The app page shows the app's branch as a label in its title, next + to the status badge, instead of after the repository under it (#240). + +- 2026-09-29: An app's deployments page now lists only its 10 most recent + deployments, newest first, instead of 50 (#238). + - 2026-09-29: `docker-compose.yml` now sets `UPAAS_PORT` to 8080 as well as `PORT`, since upaas reads `UPAAS_PORT` first and a `UPAAS_PORT` in `.env` made it listen away from the port mapping and healthcheck; the README's Compose diff --git a/cmd/upaasd/main.go b/cmd/upaasd/main.go index 1d37fd3..29a4c29 100644 --- a/cmd/upaasd/main.go +++ b/cmd/upaasd/main.go @@ -52,6 +52,8 @@ func main() { handlers.New, server.New, ), - fx.Invoke(func(*server.Server) {}), + fx.Invoke(func(log *logger.Logger, _ *server.Server) { + log.Identify() + }), ).Run() } diff --git a/internal/docker/client.go b/internal/docker/client.go index 46d2d53..2af6f52 100644 --- a/internal/docker/client.go +++ b/internal/docker/client.go @@ -21,6 +21,7 @@ import ( "github.com/docker/docker/api/types/image" "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" + "github.com/docker/docker/api/types/versions" "github.com/docker/docker/client" "github.com/docker/docker/pkg/archive" "github.com/docker/docker/pkg/jsonmessage" @@ -61,6 +62,15 @@ var ErrInvalidBranch = errors.New("invalid branch name") // ErrInvalidCommitSHA is returned when a commit SHA is not a valid hex string. var ErrInvalidCommitSHA = errors.New("invalid commit SHA") +// ErrBuildKitUnavailable is returned when the Docker daemon is too old to +// build with BuildKit. +var ErrBuildKitUnavailable = errors.New("BuildKit is unavailable on the Docker daemon") + +// minBuildKitAPIVersion is the API version of Docker Engine 18.09, the first +// that builds with BuildKit when asked to without experimental mode. Older +// daemons refuse the request or silently use the legacy builder. +const minBuildKitAPIVersion = "1.39" + // validBranchRe matches safe git branch names. var validBranchRe = regexp.MustCompile(`^[a-zA-Z0-9._/\-]+$`) @@ -595,6 +605,20 @@ func (c *Client) performBuild( ctx context.Context, opts BuildImageOptions, ) (ImageID, error) { + server, err := c.docker.ServerVersion(ctx) + if err != nil { + return "", fmt.Errorf("failed to get Docker version: %w", err) + } + + if versions.LessThan(server.APIVersion, minBuildKitAPIVersion) { + return "", fmt.Errorf( + "%w: Docker Engine %s (API %s) is older than 18.09 (API %s); "+ + "upgrade Docker Engine", + ErrBuildKitUnavailable, server.Version, server.APIVersion, + minBuildKitAPIVersion, + ) + } + // Create tar archive of build context tarArchive, err := archive.TarWithOptions(opts.ContextDir, &archive.TarOptions{}) if err != nil { @@ -660,7 +684,8 @@ const scannerMaxBufferSize = 1024 * 1024 // 1MB // newline-delimited JSON. BuildKit's progress arrives encoded in // "moby.buildkit.trace" messages; these are decoded and written as plain // text, as "docker build --progress=plain" shows it. Other lines, such as -// build errors, are written unchanged. +// build errors, are written unchanged. Docker ends a failed build with a line +// carrying the error; it is returned once the output is written. func (c *Client) streamBuildOutput( ctx context.Context, body io.Reader, @@ -690,6 +715,8 @@ func (c *Client) streamBuildOutput( buf := make([]byte, 0, scannerInitialBufferSize) scanner.Buffer(buf, scannerMaxBufferSize) + var buildErr error + for scanner.Scan() { line := scanner.Bytes() @@ -708,6 +735,10 @@ func (c *Client) streamBuildOutput( continue } + if err == nil && msg.Error != nil { + buildErr = msg.Error + } + // One write per line, so it is not split by the display's output. _, _ = fmt.Fprintf(out, "%s\n", line) } @@ -720,7 +751,7 @@ func (c *Client) streamBuildOutput( return fmt.Errorf("failed to read build output: %w", scanErr) } - return nil + return buildErr } func (c *Client) performClone( diff --git a/internal/docker/validation_test.go b/internal/docker/validation_test.go index 7257344..f496655 100644 --- a/internal/docker/validation_test.go +++ b/internal/docker/validation_test.go @@ -271,6 +271,8 @@ func TestPerformBuildUsesBuildKit(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc( func(w http.ResponseWriter, r *http.Request) { switch { + case strings.HasSuffix(r.URL.Path, "/version"): + _, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`)) case strings.HasSuffix(r.URL.Path, "/build"): if r.URL.Query().Get("version") != "2" { http.Error(w, "not a BuildKit build", http.StatusBadRequest) @@ -314,3 +316,82 @@ func TestPerformBuildUsesBuildKit(t *testing.T) { t.Errorf("build log is missing the build step:\n%s", buildLog.String()) } } + +// TestPerformBuildFails runs builds that fail against a fake Docker API and +// checks that each returns its own error and that no image is inspected +// afterwards. +func TestPerformBuildFails(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + engine string // Docker Engine version the fake daemon reports + apiVersion string // API version the fake daemon reports + buildOutput string + wantErr string + }{ + { + name: "build step fails", + engine: "27.3.1", + apiVersion: "1.47", + buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" + + `{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`, + wantErr: "exit code: 1", + }, + { + name: "daemon too old for BuildKit", + engine: "18.06.3-ce", + apiVersion: "1.38", + wantErr: "BuildKit is unavailable on the Docker daemon: " + + "Docker Engine 18.06.3-ce (API 1.38) is older than 18.09 (API 1.39); " + + "upgrade Docker Engine", + }, + { + // The build step's own error shows the build went ahead. + name: "daemon at API 1.39 builds", + engine: "18.09.9", + apiVersion: "1.39", + buildOutput: `{"stream":"Step 1/1 : RUN false\n"}` + "\n" + + `{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`, + wantErr: "exit code: 1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/version"): + _, _ = fmt.Fprintf(w, `{"Version":%q,"ApiVersion":%q}`, + tt.engine, tt.apiVersion) + case strings.HasSuffix(r.URL.Path, "/build"): + _, _ = w.Write([]byte(tt.buildOutput)) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + } + }, + )) + t.Cleanup(srv.Close) + + dockerAPI, err := client.NewClientWithOpts( + client.WithHost("tcp://" + srv.Listener.Addr().String()), + ) + if err != nil { + t.Fatal(err) + } + + c := &Client{docker: dockerAPI, log: slog.Default()} + + _, err = c.performBuild(t.Context(), BuildImageOptions{ + ContextDir: t.TempDir(), + Tags: []string{"upaas-test:1"}, + }) + if err == nil || err.Error() != tt.wantErr { + t.Errorf("got error %v, want %q", err, tt.wantErr) + } + }) + } +} diff --git a/internal/handlers/app.go b/internal/handlers/app.go index 22d527c..e03faf8 100644 --- a/internal/handlers/app.go +++ b/internal/handlers/app.go @@ -28,7 +28,7 @@ const ( // recentDeploymentsLimit is the number of recent deployments to show. recentDeploymentsLimit = 5 // deploymentsHistoryLimit is the number of deployments to show in history. - deploymentsHistoryLimit = 50 + deploymentsHistoryLimit = 10 ) // redirectToApp issues a SeeOther redirect to the page for the given diff --git a/internal/handlers/app_branch_test.go b/internal/handlers/app_branch_test.go new file mode 100644 index 0000000..c529006 --- /dev/null +++ b/internal/handlers/app_branch_test.go @@ -0,0 +1,46 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/upaas/internal/service/app" +) + +// TestAppPageTitleShowsBranch checks that an app's branch can be read from +// the app page title, next to the status badge, without opening the edit page. +func TestAppPageTitleShowsBranch(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{ + Name: "branch-shown-app", + RepoURL: "git@example.com:user/branch-shown-app.git", + Branch: "staging", + }) + require.NoError(t, err) + + request := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil, + ) + request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) + recorder := httptest.NewRecorder() + + testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + + // The title row runs from the app name heading to the end of its div. + _, afterHeading, found := strings.Cut(recorder.Body.String(), "") + assert.Contains(t, titleRow, `x-text="statusLabel"`) + assert.Contains(t, titleRow, ">staging") +} diff --git a/internal/handlers/app_page_layout_test.go b/internal/handlers/app_page_layout_test.go new file mode 100644 index 0000000..002ff3c --- /dev/null +++ b/internal/handlers/app_page_layout_test.go @@ -0,0 +1,76 @@ +package handlers_test + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sneak.berlin/go/upaas/internal/service/app" +) + +// TestAppPageLayout checks the app page's width, the order of its sections, +// and the height of its two log boxes. +func TestAppPageLayout(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + + createdApp, err := testCtx.appSvc.CreateApp(t.Context(), app.CreateAppInput{ + Name: "layout-app", + RepoURL: "git@example.com:user/layout-app.git", + }) + require.NoError(t, err) + + request := httptest.NewRequestWithContext( + t.Context(), http.MethodGet, "/apps/"+createdApp.ID, nil, + ) + request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) + recorder := httptest.NewRecorder() + + testCtx.handlers.HandleAppDetail().ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + + body := recorder.Body.String() + + _, afterMain, found := strings.Cut(body, "") + assert.Contains(t, mainTag, "max-width: 84rem;") + + sectionTitles := []string{ + "Container Logs", + "Deploy Key", + "Webhook URL", + "Last Deployment Build Logs", + "Environment Variables", + "Docker Labels", + "Volume Mounts", + "Port Mappings", + "Recent Deployments", + "Danger Zone", + } + + previousIndex := -1 + + for _, title := range sectionTitles { + index := strings.Index(body, ">"+title+"") + require.NotEqual(t, -1, index, "app page has no %q section", title) + assert.Greater(t, index, previousIndex, "%q section is out of order", title) + + previousIndex = index + } + + for _, logBox := range []string{"containerLogsWrapper", "buildLogsWrapper"} { + _, afterRef, found := strings.Cut(body, `x-ref="`+logBox+`"`) + require.True(t, found, "app page has no %s", logBox) + + logBoxTag, _, _ := strings.Cut(afterRef, ">") + assert.Contains(t, logBoxTag, "max-height: 800px;", logBox) + } +} diff --git a/internal/handlers/handlers_test.go b/internal/handlers/handlers_test.go index 6aa2037..211d3c6 100644 --- a/internal/handlers/handlers_test.go +++ b/internal/handlers/handlers_test.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/go-chi/chi/v5" "github.com/stretchr/testify/assert" @@ -1148,6 +1149,73 @@ func TestHandleCancelDeployReturns404ForUnknownApp(t *testing.T) { assert.Equal(t, http.StatusNotFound, recorder.Code) } +// TestHandleAppDeploymentsShowsTenNewest verifies the deployments page +// lists only the 10 most recent deployments, newest first. +func TestHandleAppDeploymentsShowsTenNewest(t *testing.T) { + t.Parallel() + + testCtx := setupTestHandlers(t) + createdApp := createTestApp(t, testCtx, "deployments-page-app") + + // Create 12 deployments, each started one minute after the one before. + firstStart := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + ids := make([]int64, 0, 12) + + for idx := range 12 { + deployment := models.NewDeployment(testCtx.database) + deployment.AppID = createdApp.ID + deployment.Status = models.DeploymentStatusSuccess + require.NoError(t, deployment.Save(context.Background())) + + _, err := testCtx.database.Exec( + context.Background(), + "UPDATE deployments SET started_at = ? WHERE id = ?", + firstStart.Add(time.Duration(idx)*time.Minute), + deployment.ID, + ) + require.NoError(t, err) + + ids = append(ids, deployment.ID) + } + + request := httptest.NewRequestWithContext( + t.Context(), + http.MethodGet, + "/apps/"+createdApp.ID+"/deployments", + nil, + ) + request = addChiURLParams(request, map[string]string{"id": createdApp.ID}) + recorder := httptest.NewRecorder() + + handler := testCtx.handlers.HandleAppDeployments() + handler.ServeHTTP(recorder, request) + + require.Equal(t, http.StatusOK, recorder.Code) + + body := recorder.Body.String() + card := func(id int64) string { + return `data-deployment-id="` + strconv.FormatInt(id, 10) + `"` + } + + assert.Equal(t, 10, strings.Count(body, `data-deployment-id="`)) + + // The two oldest are left out. + assert.NotContains(t, body, card(ids[0])) + assert.NotContains(t, body, card(ids[1])) + + // The ten newest are shown, newest first. + previous := -1 + + for idx := len(ids) - 1; idx >= 2; idx-- { + position := strings.Index(body, card(ids[idx])) + require.Greater(t, position, previous, + "deployment %d missing or out of order", ids[idx]) + + previous = position + } +} + func TestHandleWebhookReturns404ForUnknownSecret(t *testing.T) { t.Parallel() diff --git a/internal/service/deploy/deploy.go b/internal/service/deploy/deploy.go index e2e1d30..4245be2 100644 --- a/internal/service/deploy/deploy.go +++ b/internal/service/deploy/deploy.go @@ -923,7 +923,6 @@ func (svc *Service) buildImage( // Create log writer that flushes build output to deployment logs every second logWriter := newDeploymentLogWriter(ctx, deployment) - defer logWriter.Close() // BuildImage creates a tar archive from the local filesystem, // so it needs the container path where files exist, not the host path. @@ -933,6 +932,10 @@ func (svc *Service) buildImage( Tags: []string{imageTag}, LogWriter: logWriter, }) + + // Write the rest of the build output to the log before the result. + logWriter.Close() + if err != nil { svc.notify.NotifyBuildFailed(ctx, app, deployment, err) svc.failDeployment( diff --git a/internal/service/deploy/deploy_build_test.go b/internal/service/deploy/deploy_build_test.go new file mode 100644 index 0000000..a2cf9f4 --- /dev/null +++ b/internal/service/deploy/deploy_build_test.go @@ -0,0 +1,91 @@ +package deploy_test + +import ( + "context" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/fx/fxtest" + + "sneak.berlin/go/upaas/internal/config" + "sneak.berlin/go/upaas/internal/database" + "sneak.berlin/go/upaas/internal/docker" + "sneak.berlin/go/upaas/internal/logger" + "sneak.berlin/go/upaas/internal/models" + "sneak.berlin/go/upaas/internal/service/deploy" +) + +// TestBuildImageLogsBuildErrorBeforeDeployError runs a build that fails +// against a fake Docker API and checks that the deploy fails with the +// build's own error, which the deployment log shows before the deploy's. +func TestBuildImageLogsBuildErrorBeforeDeployError(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch { + case strings.HasSuffix(r.URL.Path, "/containers/create"): + _, _ = w.Write([]byte(`{"Id":"gitcontainer"}`)) + case strings.HasSuffix(r.URL.Path, "/version"): + _, _ = w.Write([]byte(`{"Version":"27.3.1","ApiVersion":"1.47"}`)) + case strings.HasSuffix(r.URL.Path, "/build"): + _, _ = w.Write([]byte(`{"stream":"Step 1/1 : RUN false\n"}` + "\n" + + `{"errorDetail":{"message":"exit code: 1"},"error":"exit code: 1"}`)) + default: + // The steps of the git clone, which succeeds. + _, _ = w.Write([]byte(`{}`)) + } + }, + )) + t.Cleanup(srv.Close) + + log := slog.New(slog.NewTextHandler(os.Stderr, nil)) + lifecycle := fxtest.NewLifecycle(t) + + dockerClient, err := docker.New(lifecycle, docker.Params{ + Logger: logger.NewForTest(log), + Config: &config.Config{DockerHost: "tcp://" + srv.Listener.Addr().String()}, + }) + require.NoError(t, err) + + lifecycle.RequireStart() + t.Cleanup(lifecycle.RequireStop) + + db := database.NewTestDatabase(t) + ctx := context.Background() + + app := models.NewApp(db) + app.ID = "buildapp-id" + app.Name = "buildapp" + app.Branch = "main" + require.NoError(t, app.Save(ctx)) + + deployment := models.NewDeployment(db) + deployment.AppID = app.ID + require.NoError(t, deployment.Save(ctx)) + + dataDir := t.TempDir() + cfg := &config.Config{DataDir: dataDir, HostDataDir: dataDir} + + // The service has no notify service: the app has no ntfy topic and no + // Slack webhook, so the build failure notification sends nothing. + svc := deploy.NewTestServiceWithConfig(log, cfg, dockerClient) + + _, err = svc.BuildImage(ctx, app, deployment) + require.EqualError(t, err, "failed to build image: exit code: 1") + + logs := deployment.Logs.String + buildError := strings.Index(logs, "ERROR: exit code: 1") + deployError := strings.Index(logs, "ERROR: failed to build image: exit code: 1") + + require.NotEqual(t, -1, buildError, logs) + assert.Less(t, buildError, deployError, logs) +} diff --git a/internal/service/deploy/export_test.go b/internal/service/deploy/export_test.go index 5b68733..95ce82e 100644 --- a/internal/service/deploy/export_test.go +++ b/internal/service/deploy/export_test.go @@ -100,6 +100,15 @@ func (svc *Service) RecordDeployedImage( return svc.recordDeployedImage(ctx, app, deployment, imageID) } +// BuildImage exposes buildImage for testing. +func (svc *Service) BuildImage( + ctx context.Context, + app *models.App, + deployment *models.Deployment, +) (docker.ImageID, error) { + return svc.buildImage(ctx, app, deployment) +} + // BuildContainerOptionsExported exposes buildContainerOptions for testing. func (svc *Service) BuildContainerOptionsExported( ctx context.Context, diff --git a/templates/app_detail.html b/templates/app_detail.html index ecb085e..eb6f146 100644 --- a/templates/app_detail.html +++ b/templates/app_detail.html @@ -5,7 +5,7 @@ {{define "content"}} {{template "nav" .}} -
-
+

{{.App.Name}}

+ {{.App.Branch}}
-

{{.App.RepoURL}}@{{.App.Branch}}

+

{{.App.RepoURL}}

Edit @@ -53,6 +54,26 @@
+ +
+
+

Container Logs

+ +
+
+
+

+            
+ +
+
+

Deploy Key

@@ -100,6 +121,26 @@
+ +
+
+

Last Deployment Build Logs

+ +
+
+
+

+            
+ +
+
+

Environment Variables

@@ -353,26 +394,6 @@
- -
-
-

Container Logs

- -
-
-
-

-            
- -
-
-
@@ -412,26 +433,6 @@
- -
-
-

Last Deployment Build Logs

- -
-
-
-

-            
- -
-
-

Danger Zone