diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 81c16ba784798..23389633f0b87 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -7490,6 +7490,9 @@ const docTemplate = `{ "CoderSessionToken": [] } ], + "consumes": [ + "application/json" + ], "tags": [ "Workspaces" ], @@ -7503,6 +7506,14 @@ const docTemplate = `{ "name": "workspace", "in": "path", "required": true + }, + { + "description": "Post workspace usage request", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest" + } } ], "responses": { @@ -9200,19 +9211,22 @@ const docTemplate = `{ "example", "auto-fill-parameters", "multi-organization", - "custom-roles" + "custom-roles", + "workspace-usage" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", - "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." + "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", - "ExperimentCustomRoles" + "ExperimentCustomRoles", + "ExperimentWorkspaceUsage" ] }, "codersdk.ExternalAuth": { @@ -10150,6 +10164,18 @@ const docTemplate = `{ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -11902,6 +11928,21 @@ const docTemplate = `{ } } }, + "codersdk.UsageAppName": { + "type": "string", + "enum": [ + "vscode", + "jetbrains", + "reconnecting-pty", + "ssh" + ], + "x-enum-varnames": [ + "UsageAppNameVscode", + "UsageAppNameJetbrains", + "UsageAppNameReconnectingPty", + "UsageAppNameSSH" + ] + }, "codersdk.User": { "type": "object", "required": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 7859bcb5ded02..d6233524067fd 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -6628,6 +6628,7 @@ "CoderSessionToken": [] } ], + "consumes": ["application/json"], "tags": ["Workspaces"], "summary": "Post Workspace Usage by ID", "operationId": "post-workspace-usage-by-id", @@ -6639,6 +6640,14 @@ "name": "workspace", "in": "path", "required": true + }, + { + "description": "Post workspace usage request", + "name": "request", + "in": "body", + "schema": { + "$ref": "#/definitions/codersdk.PostWorkspaceUsageRequest" + } } ], "responses": { @@ -8236,19 +8245,22 @@ "example", "auto-fill-parameters", "multi-organization", - "custom-roles" + "custom-roles", + "workspace-usage" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", "ExperimentCustomRoles": "Allows creating runtime custom roles", "ExperimentExample": "This isn't used for anything.", - "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed." + "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", - "ExperimentCustomRoles" + "ExperimentCustomRoles", + "ExperimentWorkspaceUsage" ] }, "codersdk.ExternalAuth": { @@ -9131,6 +9143,18 @@ } } }, + "codersdk.PostWorkspaceUsageRequest": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "format": "uuid" + }, + "app_name": { + "$ref": "#/definitions/codersdk.UsageAppName" + } + } + }, "codersdk.PprofConfig": { "type": "object", "properties": { @@ -10791,6 +10815,16 @@ } } }, + "codersdk.UsageAppName": { + "type": "string", + "enum": ["vscode", "jetbrains", "reconnecting-pty", "ssh"], + "x-enum-varnames": [ + "UsageAppNameVscode", + "UsageAppNameJetbrains", + "UsageAppNameReconnectingPty", + "UsageAppNameSSH" + ] + }, "codersdk.User": { "type": "object", "required": ["created_at", "email", "id", "username"], diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1b3f076e8f4bf..22a269fc5fb7f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "slices" "strconv" "time" @@ -15,6 +16,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" @@ -1105,7 +1107,9 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { // @ID post-workspace-usage-by-id // @Security CoderSessionToken // @Tags Workspaces +// @Accept json // @Param workspace path string true "Workspace ID" format(uuid) +// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request" // @Success 204 // @Router /workspaces/{workspace}/usage [post] func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { @@ -1116,6 +1120,102 @@ func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) { } api.statsReporter.TrackUsage(workspace.ID) + + if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) { + // Continue previous behavior if the experiment is not enabled. + rw.WriteHeader(http.StatusNoContent) + return + } + + if r.Body == http.NoBody { + // Continue previous behavior if no body is present. + rw.WriteHeader(http.StatusNoContent) + return + } + + ctx := r.Context() + var req codersdk.PostWorkspaceUsageRequest + if !httpapi.Read(ctx, rw, r, &req) { + return + } + + if req.AgentID == uuid.Nil && req.AppName == "" { + // Continue previous behavior if body is empty. + rw.WriteHeader(http.StatusNoContent) + return + } + if req.AgentID == uuid.Nil { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "agent_id", + Detail: "must be set when app_name is set", + }}, + }) + return + } + if req.AppName == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "app_name", + Detail: "must be set when agent_id is set", + }}, + }) + return + } + if !slices.Contains(codersdk.AllowedAppNames, req.AppName) { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Invalid request", + Validations: []codersdk.ValidationError{{ + Field: "app_name", + Detail: fmt.Sprintf("must be one of %v", codersdk.AllowedAppNames), + }}, + }) + return + } + + stat := &proto.Stats{ + ConnectionCount: 1, + } + switch req.AppName { + case codersdk.UsageAppNameVscode: + stat.SessionCountVscode = 1 + case codersdk.UsageAppNameJetbrains: + stat.SessionCountJetbrains = 1 + case codersdk.UsageAppNameReconnectingPty: + stat.SessionCountReconnectingPty = 1 + case codersdk.UsageAppNameSSH: + stat.SessionCountSsh = 1 + default: + // This means the app_name is in the codersdk.AllowedAppNames but not being + // handled by this switch statement. + httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName)) + return + } + + agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID) + if err != nil { + if httpapi.Is404Error(err) { + httpapi.ResourceNotFound(rw) + return + } + httpapi.InternalServerError(rw, err) + return + } + + template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + + err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), workspace, agent, template.Name, stat) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + rw.WriteHeader(http.StatusNoContent) } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index a20a26d2ab161..e5a01df9f8edc 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3371,3 +3371,127 @@ func TestWorkspaceFavoriteUnfavorite(t *testing.T) { require.ErrorAs(t, err, &sdkErr) require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) } + +func TestWorkspaceUsageTracking(t *testing.T) { + t.Parallel() + t.Run("NoExperiment", func(t *testing.T) { + t.Parallel() + client, db := coderdtest.NewWithDatabase(t, nil) + user := coderdtest.CreateFirstUser(t, client) + tmpDir := t.TempDir() + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + + // continue legacy behavior + err := client.PostWorkspaceUsage(ctx, r.Workspace.ID) + require.NoError(t, err) + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + require.NoError(t, err) + }) + t.Run("Experiment", func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium) + defer cancel() + dv := coderdtest.DeploymentValues(t) + dv.Experiments = []string{string(codersdk.ExperimentWorkspaceUsage)} + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{ + DeploymentValues: dv, + }) + user := coderdtest.CreateFirstUser(t, client) + tmpDir := t.TempDir() + org := dbgen.Organization(t, db, database.Organization{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{ + UserID: user.UserID, + OrganizationID: org.ID, + }) + templateVersion := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: org.ID, + CreatedBy: user.UserID, + }) + template := dbgen.Template(t, db, database.Template{ + OrganizationID: org.ID, + ActiveVersionID: templateVersion.ID, + CreatedBy: user.UserID, + DefaultTTL: int64(8 * time.Hour), + }) + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + ActivityBumpMillis: 8 * time.Hour.Milliseconds(), + }) + require.NoError(t, err) + r := dbfake.WorkspaceBuild(t, db, database.Workspace{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + TemplateID: template.ID, + Ttl: sql.NullInt64{Valid: true, Int64: int64(8 * time.Hour)}, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + agents[0].Directory = tmpDir + return agents + }).Do() + + // continue legacy behavior + err = client.PostWorkspaceUsage(ctx, r.Workspace.ID) + require.NoError(t, err) + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{}) + require.NoError(t, err) + + workspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + + // only agent id fails + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + }) + require.ErrorContains(t, err, "agent_id") + // only app name fails + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AppName: "ssh", + }) + require.ErrorContains(t, err, "app_name") + // unknown app name fails + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "unknown", + }) + require.ErrorContains(t, err, "app_name") + + // vscode works + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "vscode", + }) + require.NoError(t, err) + // jetbrains works + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "jetbrains", + }) + require.NoError(t, err) + // reconnecting-pty works + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "reconnecting-pty", + }) + require.NoError(t, err) + // ssh works + err = client.PostWorkspaceUsageWithBody(ctx, r.Workspace.ID, codersdk.PostWorkspaceUsageRequest{ + AgentID: workspace.LatestBuild.Resources[0].Agents[0].ID, + AppName: "ssh", + }) + require.NoError(t, err) + + // ensure deadline has been bumped + newWorkspace, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err) + require.True(t, workspace.LatestBuild.Deadline.Valid) + require.True(t, newWorkspace.LatestBuild.Deadline.Valid) + require.Greater(t, newWorkspace.LatestBuild.Deadline.Time, workspace.LatestBuild.Deadline.Time) + }) +} diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 21d33ebc81dc0..b67964d6a985c 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2233,6 +2233,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles + ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking ) // ExperimentsAll should include all experiments that are safe for diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 0007e85de8ee4..69472f8d4579d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -316,7 +316,43 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx return nil } +type PostWorkspaceUsageRequest struct { + AgentID uuid.UUID `json:"agent_id" format:"uuid"` + AppName UsageAppName `json:"app_name"` +} + +type UsageAppName string + +const ( + UsageAppNameVscode UsageAppName = "vscode" + UsageAppNameJetbrains UsageAppName = "jetbrains" + UsageAppNameReconnectingPty UsageAppName = "reconnecting-pty" + UsageAppNameSSH UsageAppName = "ssh" +) + +var AllowedAppNames = []UsageAppName{ + UsageAppNameVscode, + UsageAppNameJetbrains, + UsageAppNameReconnectingPty, + UsageAppNameSSH, +} + +// PostWorkspaceUsage marks the workspace as having been used recently and records an app stat. +func (c *Client) PostWorkspaceUsageWithBody(ctx context.Context, id uuid.UUID, req PostWorkspaceUsageRequest) error { + path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) + res, err := c.Request(ctx, http.MethodPost, path, req) + if err != nil { + return xerrors.Errorf("post workspace usage: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + // PostWorkspaceUsage marks the workspace as having been used recently. +// Deprecated: use PostWorkspaceUsageWithBody instead func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { path := fmt.Sprintf("/api/v2/workspaces/%s/usage", id.String()) res, err := c.Request(ctx, http.MethodPost, path, nil) @@ -330,14 +366,52 @@ func (c *Client) PostWorkspaceUsage(ctx context.Context, id uuid.UUID) error { return nil } +// UpdateWorkspaceUsageWithBodyContext periodically posts workspace usage for the workspace +// with the given id and app name in the background. +// The caller is responsible for calling the returned function to stop the background +// process. +func (c *Client) UpdateWorkspaceUsageWithBodyContext(ctx context.Context, workspaceID uuid.UUID, req PostWorkspaceUsageRequest) func() { + hbCtx, hbCancel := context.WithCancel(ctx) + // Perform one initial update + err := c.PostWorkspaceUsageWithBody(hbCtx, workspaceID, req) + if err != nil { + c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) + } + ticker := time.NewTicker(time.Minute) + doneCh := make(chan struct{}) + go func() { + defer func() { + ticker.Stop() + close(doneCh) + }() + for { + select { + case <-ticker.C: + err := c.PostWorkspaceUsageWithBody(hbCtx, workspaceID, req) + if err != nil { + c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) + } + case <-hbCtx.Done(): + return + } + } + }() + return func() { + hbCancel() + <-doneCh + } +} + // UpdateWorkspaceUsageContext periodically posts workspace usage for the workspace // with the given id in the background. // The caller is responsible for calling the returned function to stop the background // process. -func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, id uuid.UUID) func() { +// Deprecated: use UpdateWorkspaceUsageContextWithBody instead +func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, workspaceID uuid.UUID) func() { hbCtx, hbCancel := context.WithCancel(ctx) // Perform one initial update - if err := c.PostWorkspaceUsage(hbCtx, id); err != nil { + err := c.PostWorkspaceUsage(hbCtx, workspaceID) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage", slog.Error(err)) } ticker := time.NewTicker(time.Minute) @@ -350,7 +424,8 @@ func (c *Client) UpdateWorkspaceUsageContext(ctx context.Context, id uuid.UUID) for { select { case <-ticker.C: - if err := c.PostWorkspaceUsage(hbCtx, id); err != nil { + err := c.PostWorkspaceUsage(hbCtx, workspaceID) + if err != nil { c.logger.Warn(ctx, "failed to post workspace usage in background", slog.Error(err)) } case <-hbCtx.Done(): diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 348ce54e11ba3..905cb45479b54 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2360,6 +2360,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `auto-fill-parameters` | | `multi-organization` | | `custom-roles` | +| `workspace-usage` | ## codersdk.ExternalAuth @@ -3359,6 +3360,22 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `icon` | string | false | | | | `name` | string | true | | | +## codersdk.PostWorkspaceUsageRequest + +```json +{ + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_name": "vscode" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | ---------------------------------------------- | -------- | ------------ | ----------- | +| `agent_id` | string | false | | | +| `app_name` | [codersdk.UsageAppName](#codersdkusageappname) | false | | | + ## codersdk.PprofConfig ```json @@ -5227,6 +5244,23 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `share_level` | `authenticated` | | `share_level` | `public` | +## codersdk.UsageAppName + +```json +"vscode" +``` + +### Properties + +#### Enumerated Values + +| Value | +| ------------------ | +| `vscode` | +| `jetbrains` | +| `reconnecting-pty` | +| `ssh` | + ## codersdk.User ```json diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index 886f8401f7d7e..f16d9be857fef 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -1397,16 +1397,27 @@ To perform this operation, you must be authenticated. [Learn more](authenticatio ```shell # Example request using curl curl -X POST http://coder-server:8080/api/v2/workspaces/{workspace}/usage \ + -H 'Content-Type: application/json' \ -H 'Coder-Session-Token: API_KEY' ``` `POST /workspaces/{workspace}/usage` +> Body parameter + +```json +{ + "agent_id": "2b1e3b65-2c04-4fa2-a2d7-467901e98978", + "app_name": "vscode" +} +``` + ### Parameters -| Name | In | Type | Required | Description | -| ----------- | ---- | ------------ | -------- | ------------ | -| `workspace` | path | string(uuid) | true | Workspace ID | +| Name | In | Type | Required | Description | +| ----------- | ---- | ---------------------------------------------------------------------------------- | -------- | ---------------------------- | +| `workspace` | path | string(uuid) | true | Workspace ID | +| `body` | body | [codersdk.PostWorkspaceUsageRequest](schemas.md#codersdkpostworkspaceusagerequest) | false | Post workspace usage request | ### Responses diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a53717e3e0229..312f43dcd5bb4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -841,6 +841,12 @@ export interface PostOAuth2ProviderAppRequest { readonly icon: string; } +// From codersdk/workspaces.go +export interface PostWorkspaceUsageRequest { + readonly agent_id: string; + readonly app_name: UsageAppName; +} + // From codersdk/deployment.go export interface PprofConfig { readonly enable: boolean; @@ -1943,12 +1949,14 @@ export type Experiment = | "auto-fill-parameters" | "custom-roles" | "example" - | "multi-organization"; + | "multi-organization" + | "workspace-usage"; export const Experiments: Experiment[] = [ "auto-fill-parameters", "custom-roles", "example", "multi-organization", + "workspace-usage", ]; // From codersdk/deployment.go @@ -2243,6 +2251,15 @@ export const TemplateVersionWarnings: TemplateVersionWarning[] = [ "UNSUPPORTED_WORKSPACES", ]; +// From codersdk/workspaces.go +export type UsageAppName = "jetbrains" | "reconnecting-pty" | "ssh" | "vscode"; +export const UsageAppNames: UsageAppName[] = [ + "jetbrains", + "reconnecting-pty", + "ssh", + "vscode", +]; + // From codersdk/users.go export type UserStatus = "active" | "dormant" | "suspended"; export const UserStatuses: UserStatus[] = ["active", "dormant", "suspended"]; pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy