Compare commits

...

21 Commits

Author SHA1 Message Date
Steven Masley 685c5fdbdb
Merge 38623111c8 into 13dd526f11 2024-05-03 22:42:09 +00:00
Steven Masley 38623111c8
remove comments 2024-05-03 17:41:43 -05:00
Steven Masley f5e09cd130
fixup hidden input field 2024-05-03 17:26:10 -05:00
Colin Adler 13dd526f11
fix: prevent stdlib logging from messing up ssh (#13161)
Fixes https://github.com/coder/coder/issues/13144
2024-05-03 22:12:06 +00:00
recanman b20c63c185
fix: install openrc service on alpine (#12294) (#12870)
* fix: install openrc service on alpine (#12294)

* fmt

---------

Co-authored-by: Kyle Carberry <kyle@coder.com>
2024-05-03 21:09:23 +00:00
Michael Brewer 060f023174
feat: mask coder login token to enhance security (#12948)
* feat(login): treat coder token as a secret

* Update login.go
2024-05-03 17:03:13 -04:00
Steven Masley 604227e46d
disable leaky test 2024-05-03 12:18:28 -05:00
Steven Masley c25f6c415e
Run docker e2e in CI once 2024-05-03 12:11:38 -05:00
Steven Masley 50501e88d1
create e2e terraform test with docker template 2024-05-03 12:10:39 -05:00
Steven Masley f4d7c0d11e
update import order 2024-05-03 10:55:01 -05:00
Steven Masley 8fdaf2c44c
begin work on terraform e2e test 2024-05-03 10:55:01 -05:00
Steven Masley de3f81d9a9
fixup config 2024-05-03 10:55:01 -05:00
Steven Masley e82b2c34dc
chore: terraform provisioners enabled in e2e by default
Opt out supported for "lite" tests. Environment checking happens
to verify terraform + docker exist before running.
2024-05-03 10:55:01 -05:00
Steven Masley f104148e96
remove unused import 2024-05-03 10:55:01 -05:00
Steven Masley 736df67786
remove ui pills for provisioners on health page 2024-05-03 10:55:01 -05:00
Steven Masley 2a09af35d3
update golden files 2024-05-03 10:55:01 -05:00
Steven Masley f4dbfb5913
make gen 2024-05-03 10:55:01 -05:00
Steven Masley e12e721236
update golden files 2024-05-03 10:53:24 -05:00
Steven Masley ae89373050
update golden files 2024-05-03 10:39:14 -05:00
Steven Masley 5294740a62
add icon to health provisioner page for prov type 2024-05-03 10:39:01 -05:00
Steven Masley c8107597a5
make gen 2024-05-03 10:39:01 -05:00
16 changed files with 395 additions and 144 deletions

View File

@ -222,5 +222,7 @@
"go.testFlags": ["-short", "-coverpkg=./..."],
// We often use a version of TypeScript that's ahead of the version shipped
// with VS Code.
"typescript.tsdk": "./site/node_modules/typescript/lib"
"typescript.tsdk": "./site/node_modules/typescript/lib",
// Playwright tests in VSCode will open a browser to live "view" the test.
"playwright.reuseBrowser": true
}

View File

@ -287,7 +287,8 @@ func (r *RootCmd) login() *serpent.Command {
}
sessionToken, err = cliui.Prompt(inv, cliui.PromptOptions{
Text: "Paste your token here:",
Text: "Paste your token here:",
Secret: true,
Validate: func(token string) error {
client.SetSessionToken(token)
_, err := client.User(ctx, codersdk.Me)

View File

@ -1441,7 +1441,7 @@ func newProvisionerDaemon(
connector[string(database.ProvisionerTypeTerraform)] = sdkproto.NewDRPCProvisionerClient(terraformClient)
default:
return nil, fmt.Errorf("unknown provisioner type %q", provisionerType)
return nil, xerrors.Errorf("unknown provisioner type %q", provisionerType)
}
}

View File

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
@ -79,6 +80,10 @@ func (r *RootCmd) ssh() *serpent.Command {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
// Prevent unnecessary logs from the stdlib from messing up the TTY.
// See: https://github.com/coder/coder/issues/13144
log.SetOutput(io.Discard)
logger := inv.Logger
defer func() {
if retErr != nil {

38
scripts/linux-pkg/coder-openrc Executable file
View File

@ -0,0 +1,38 @@
#!/sbin/openrc-run
name=coder
description="Coder - Self-hosted developer workspaces on your infra"
document="https://coder.com/docs/coder-oss"
depend() {
need net
after net-online
use dns logger
}
checkpath --directory --owner coder:coder --mode 0700 /var/cache/coder
start_pre() {
if [ ! -f /etc/coder.d/coder.env ]; then
eerror "/etc/coder.d/coder.env file does not exist"
return 1
fi
# Read and export environment variables ignoring comment lines and blank lines
while IFS= read -r line; do
# Skip blank or comment lines
if [ -z "$line" ] || [[ "$line" =~ ^# ]]; then
continue
fi
export "$line"
done < /etc/coder.d/coder.env
}
command="/usr/bin/coder"
command_args="server"
command_user="coder:coder"
command_background="yes"
pidfile="/run/coder.pid"
restart="always"
restart_delay="5"
stop_timeout="90"

View File

@ -0,0 +1,39 @@
#!/sbin/openrc-run
name=coder-workspace-proxy
description="Coder - external workspace proxy server"
document="https://coder.com/docs/coder-oss"
depend() {
need net
after net-online
use dns logger
}
checkpath --directory --owner coder:coder --mode 0700 /var/cache/coder
start_pre() {
if [ ! -f /etc/coder.d/coder-workspace-proxy.env ]; then
eerror "/etc/coder.d/coder-workspace-proxy.env file does not exist"
return 1
fi
# Read and export environment variables ignoring comment lines and blank lines
while IFS= read -r line; do
# Skip blank or comment lines
if [ -z "$line" ] || [[ "$line" =~ ^# ]]; then
continue
fi
export "$line"
done < /etc/coder.d/coder-workspace-proxy.env
}
command="/usr/bin/coder"
command_args="workspace-proxy server"
command_user="coder:coder"
command_background="yes"
pidfile="/run/coder-workspace-proxy.pid"
restart="always"
restart_delay="5"
stop_timeout="90"

View File

@ -0,0 +1,29 @@
name: coder
platform: linux
arch: "${GOARCH}"
version: "${CODER_VERSION}"
version_schema: semver
release: 1
vendor: Coder
homepage: https://coder.com
maintainer: Coder <support@coder.com>
description: |
Provision development environments with infrastructure with code
license: AGPL-3.0
suggests:
- postgresql
scripts:
preinstall: preinstall.sh
contents:
- src: coder
dst: /usr/bin/coder
- src: coder.env
dst: /etc/coder.d/coder.env
type: "config|noreplace"
- src: coder-workspace-proxy-openrc
dst: /etc/init.d/coder-workspace-proxy
- src: coder-openrc
dst: /etc/init.d/coder

View File

@ -89,9 +89,16 @@ ln "$(realpath scripts/linux-pkg/coder.service)" "$temp_dir/"
ln "$(realpath scripts/linux-pkg/nfpm.yaml)" "$temp_dir/"
ln "$(realpath scripts/linux-pkg/preinstall.sh)" "$temp_dir/"
nfpm_config_file="nfpm.yaml"
# Use nfpm-alpine.yaml when building for Alpine (OpenRC).
if [[ "$format" == "apk" ]]; then
nfpm_config_file="nfpm-alpine.yaml"
fi
pushd "$temp_dir"
GOARCH="$arch" CODER_VERSION="$version" nfpm package \
-f nfpm.yaml \
-f "$nfpm_config_file" \
-p "$format" \
-t "$output_path" \
1>&2

View File

@ -39,6 +39,10 @@ export const requireEnterpriseTests = Boolean(
);
export const enterpriseLicense = process.env.CODER_E2E_ENTERPRISE_LICENSE ?? "";
// Disabling terraform tests is optional for environments without Docker + Terraform.
// By default, we opt into these tests.
export const requireTerraformTests = !process.env.CODER_E2E_DISABLE_TERRAFORM;
// Fake experiments to verify that site presents them as enabled.
export const e2eFakeExperiment1 = "e2e-fake-experiment-1";
export const e2eFakeExperiment2 = "e2e-fake-experiment-2";

View File

@ -19,6 +19,7 @@ import {
enterpriseLicense,
prometheusPort,
requireEnterpriseTests,
requireTerraformTests,
} from "./constants";
import { expectUrl } from "./expectUrl";
import {
@ -43,6 +44,15 @@ export function requiresEnterpriseLicense() {
test.skip(!enterpriseLicense);
}
// requiresTerraform by default is enabled.
export function requiresTerraform() {
if (requireTerraformTests) {
return;
}
test.skip(!requireTerraformTests);
}
// createWorkspace creates a workspace for a template.
// It does not wait for it to be running, but it does navigate to the page.
export const createWorkspace = async (
@ -149,25 +159,52 @@ export const verifyParameters = async (
}
};
// StarterTemplates are ids of starter templates that can be used in place of
// the responses payload. These starter templates will require real provisioners.
export enum StarterTemplates {
STARTER_DOCKER = "docker",
}
function isStarterTemplate(
input: EchoProvisionerResponses | StarterTemplates | undefined,
): input is StarterTemplates {
if (!input) {
return false;
}
return typeof input === "string";
}
// createTemplate navigates to the /templates/new page and uploads a template
// with the resources provided in the responses argument.
export const createTemplate = async (
page: Page,
responses?: EchoProvisionerResponses,
responses?: EchoProvisionerResponses | StarterTemplates,
): Promise<string> => {
// Required to have templates submit their provisioner type as echo!
await page.addInitScript({
content: "window.playwright = true",
});
let path = "/templates/new";
if (isStarterTemplate(responses)) {
path += `?exampleId=${responses}`;
}
await page.goto("/templates/new", { waitUntil: "domcontentloaded" });
await page.goto(path, { waitUntil: "domcontentloaded" });
await expectUrl(page).toHavePathName("/templates/new");
await page.getByTestId("file-upload").setInputFiles({
buffer: await createTemplateVersionTar(responses),
mimeType: "application/x-tar",
name: "template.tar",
});
if (!isStarterTemplate(responses)) {
await page
.locator(`xpath=//input[@data-testid="provisioner-type-input"]`)
.evaluate((el: HTMLElement) => {
// This is a little jank, but the "setAttribute" updates the HTML, but not the formik values.
el.setAttribute("value", "echo");
// This '.click()' activates the onClick handler that tells the input to update it's formik value.
el.click();
});
await page.getByTestId("file-upload").setInputFiles({
buffer: await createTemplateVersionTar(responses),
mimeType: "application/x-tar",
name: "template.tar",
});
}
const name = randomName();
await page.getByLabel("Name *").fill(name);
await page.getByTestId("form-submit").click();

View File

@ -1,4 +1,5 @@
import { defineConfig } from "@playwright/test";
import { execSync } from "child_process";
import * as path from "path";
import {
coderMain,
@ -7,6 +8,7 @@ import {
e2eFakeExperiment1,
e2eFakeExperiment2,
gitAuth,
requireTerraformTests,
} from "./constants";
export const wsEndpoint = process.env.CODER_E2E_WS_ENDPOINT;
@ -14,6 +16,25 @@ export const wsEndpoint = process.env.CODER_E2E_WS_ENDPOINT;
// This is where auth cookies are stored!
export const storageState = path.join(__dirname, ".auth.json");
if (requireTerraformTests) {
try {
// If running terraform tests, verify the requirements exist in the
// environment.
//
// These execs will throw an error if the status code is non-zero.
// So if both these work, then we can launch terraform provisioners.
execSync("terraform --version");
execSync("docker --version");
} catch (e) {
throw new Error(
"Terraform provisioners require docker & terraform. " +
"At least one of these is not present in the runtime environment. To check yourself:\n" +
"\t$ terraform --version\n" +
"\t$ docker --version",
);
}
}
const localURL = (port: number, path: string): string => {
return `http://localhost:${port}${path}`;
};
@ -54,13 +75,14 @@ export default defineConfig({
`go run -tags embed ${coderMain} server`,
"--global-config $(mktemp -d -t e2e-XXXXXXXXXX)",
`--access-url=http://localhost:${coderPort}`,
`--http-address=localhost:${coderPort}`,
`--http-address=0.0.0.0:${coderPort}`,
"--in-memory",
"--telemetry=false",
"--dangerous-disable-rate-limits",
"--provisioner-daemons 10",
// TODO: Enable some terraform provisioners
"--provisioner-types=echo",
`--provisioner-types=echo${requireTerraformTests ? ",terraform" : ""}`,
`--provisioner-daemons=10`,
"--web-terminal-renderer=dom",
"--pprof-enable",
]

View File

@ -1,8 +1,10 @@
import { test, expect } from "@playwright/test";
import {
StarterTemplates,
createTemplate,
createWorkspace,
echoResponsesWithParameters,
requiresTerraform,
verifyParameters,
} from "../helpers";
import { beforeCoderTest } from "../hooks";
@ -147,3 +149,43 @@ test("create workspace with disable_param search params", async ({ page }) => {
await expect(page.getByLabel(/First parameter/i)).toBeDisabled();
await expect(page.getByLabel(/Second parameter/i)).toBeDisabled();
});
test("create docker workspace", async ({ page }) => {
test.skip(
true,
"creating docker containers is currently leaky. They are not cleaned up when the tests are over.",
);
requiresTerraform();
const template = await createTemplate(page, StarterTemplates.STARTER_DOCKER);
const _ = await createWorkspace(page, template);
// The workspace agents must be ready before we try to interact with the workspace.
await page.waitForSelector(
`//div[@role="status"][@data-testid="agent-status-ready"]`,
{
state: "visible",
},
);
// Wait for the terminal button to be visible, and click it.
const terminalButton =
"//a[@data-testid='terminal'][normalize-space()='Terminal']";
await page.waitForSelector(terminalButton, {
state: "visible",
});
// We can't click the terminal button because that opens a new tab.
// So grab the href, and manually navigate.
const terminalPageURL = await page.getAttribute(terminalButton, "href");
expect(terminalPageURL).not.toBeNull();
await page.goto(terminalPageURL!, {
waitUntil: "domcontentloaded",
});
await page.waitForSelector(
`//textarea[contains(@class,"xterm-helper-textarea")]`,
{
state: "visible",
},
);
});

View File

@ -1,11 +1,12 @@
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import { Field, FormikProvider, useFormik } from "formik";
import camelCase from "lodash/camelCase";
import capitalize from "lodash/capitalize";
import type { FC } from "react";
import * as Yup from "yup";
import type {
ProvisionerJobLog,
ProvisionerType,
Template,
TemplateExample,
TemplateVersionVariable,
@ -50,6 +51,7 @@ export interface CreateTemplateData {
parameter_values_by_name?: Record<string, string>;
user_variable_values?: VariableValue[];
allow_everyone_group_access: boolean;
provisioner_type: ProvisionerType;
}
const validationSchema = Yup.object({
@ -81,6 +83,7 @@ const defaultInitialValues: CreateTemplateData = {
allow_user_autostart: false,
allow_user_autostop: false,
allow_everyone_group_access: true,
provisioner_type: "terraform",
};
type GetInitialValuesParams = {
@ -190,133 +193,154 @@ export const CreateTemplateForm: FC<CreateTemplateFormProps> = (props) => {
const getFieldHelpers = getFormHelpers<CreateTemplateData>(form, error);
return (
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
<FormSection
title="General"
description="The name is used to identify the template in URLs and the API."
>
<FormFields>
{"starterTemplate" in props && (
<SelectedTemplate template={props.starterTemplate} />
)}
{"copiedTemplate" in props && (
<SelectedTemplate template={props.copiedTemplate} />
)}
{"upload" in props && (
<TemplateUpload
{...props.upload}
onUpload={async (file) => {
await fillNameAndDisplayWithFilename(file.name, form);
props.upload.onUpload(file);
}}
/>
)}
<TextField
{...getFieldHelpers("name")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
required
label="Name"
/>
</FormFields>
</FormSection>
{/* Display info */}
<FormSection
title="Display"
description="A friendly name, description, and icon to help developers identify your template."
>
<FormFields>
<TextField
{...getFieldHelpers("display_name")}
disabled={isSubmitting}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description", {
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
})}
disabled={isSubmitting}
rows={5}
multiline
fullWidth
label="Description"
/>
<IconField
{...getFieldHelpers("icon")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</FormSection>
{/* Variables */}
{variables && variables.length > 0 && (
<FormikProvider value={form}>
<HorizontalForm onSubmit={form.handleSubmit}>
{/* General info */}
<FormSection
ref={variablesSectionRef}
title="Variables"
description="Input variables allow you to customize templates without altering their source code."
title="General"
description="The name is used to identify the template in URLs and the API."
>
<FormFields>
{variables.map((variable, index) => (
<VariableInput
defaultValue={variable.value}
variable={variable}
disabled={isSubmitting}
key={variable.name}
onChange={async (value) => {
await form.setFieldValue("user_variable_values." + index, {
name: variable.name,
value,
});
{"starterTemplate" in props && (
<SelectedTemplate template={props.starterTemplate} />
)}
{"copiedTemplate" in props && (
<SelectedTemplate template={props.copiedTemplate} />
)}
{"upload" in props && (
<TemplateUpload
{...props.upload}
onUpload={async (file) => {
await fillNameAndDisplayWithFilename(file.name, form);
props.upload.onUpload(file);
}}
/>
))}
)}
{/*
This value is always "terraform" in production.
For testing purposes, we expose this as a hidden form element
that can be changed. For example, to "echo"
*/}
<Field
type="hidden"
{...getFieldHelpers("provisioner_type")}
data-testid="provisioner-type-input"
label="Provisioner type"
// This is a bit jank, but when you call 'setAttribute('value', 'echo') from playwright, the formik form
// is not updated. So calling 'click' will also update formik. This is super weird, but I cannot find another
// way
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Not sure what the actual type is here.
onClick={async (e: any) => {
await form.setFieldValue("provisioner_type", e.target.value);
}}
/>
<TextField
{...getFieldHelpers("name")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
required
label="Name"
/>
</FormFields>
</FormSection>
)}
<div className="flex items-center">
<FormFooter
extraActions={
logs && (
<button
type="button"
onClick={onOpenBuildLogsDrawer}
css={(theme) => ({
backgroundColor: "transparent",
border: 0,
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
color: theme.palette.text.secondary,
{/* Display info */}
<FormSection
title="Display"
description="A friendly name, description, and icon to help developers identify your template."
>
<FormFields>
<TextField
{...getFieldHelpers("display_name")}
disabled={isSubmitting}
fullWidth
label="Display name"
/>
"&:hover": {
textDecoration: "underline",
textUnderlineOffset: 4,
color: theme.palette.text.primary,
},
})}
>
Show build logs
</button>
)
}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</div>
</HorizontalForm>
<TextField
{...getFieldHelpers("description", {
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
})}
disabled={isSubmitting}
rows={5}
multiline
fullWidth
label="Description"
/>
<IconField
{...getFieldHelpers("icon")}
disabled={isSubmitting}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</FormSection>
{/* Variables */}
{variables && variables.length > 0 && (
<FormSection
ref={variablesSectionRef}
title="Variables"
description="Input variables allow you to customize templates without altering their source code."
>
<FormFields>
{variables.map((variable, index) => (
<VariableInput
defaultValue={variable.value}
variable={variable}
disabled={isSubmitting}
key={variable.name}
onChange={async (value) => {
await form.setFieldValue("user_variable_values." + index, {
name: variable.name,
value,
});
}}
/>
))}
</FormFields>
</FormSection>
)}
<div className="flex items-center">
<FormFooter
extraActions={
logs && (
<button
type="button"
onClick={onOpenBuildLogsDrawer}
css={(theme) => ({
backgroundColor: "transparent",
border: 0,
fontWeight: 500,
fontSize: 14,
cursor: "pointer",
color: theme.palette.text.secondary,
"&:hover": {
textDecoration: "underline",
textUnderlineOffset: 4,
color: theme.palette.text.primary,
},
})}
>
Show build logs
</button>
)
}
onCancel={onCancel}
isLoading={isSubmitting}
submitLabel={jobError ? "Retry" : "Create template"}
/>
</div>
</HorizontalForm>
</FormikProvider>
);
};

View File

@ -82,6 +82,7 @@ export const DuplicateTemplateView: FC<CreateTemplatePageViewProps> = ({
version: firstVersionFromFile(
templateVersionQuery.data!.job.file_id,
formData.user_variable_values,
formData.provisioner_type,
),
template: newTemplate(formData),
});

View File

@ -64,6 +64,7 @@ export const UploadTemplateView: FC<CreateTemplatePageViewProps> = ({
version: firstVersionFromFile(
uploadedFile!.hash,
formData.user_variable_values,
formData.provisioner_type,
),
template: newTemplate(formData),
});

View File

@ -1,4 +1,5 @@
import type {
CreateTemplateVersionRequest,
Entitlements,
ProvisionerType,
TemplateExample,
@ -7,10 +8,6 @@ import type {
import { calculateAutostopRequirementDaysValue } from "utils/schedule";
import type { CreateTemplateData } from "./CreateTemplateForm";
const provisioner: ProvisionerType =
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Playwright needs to use a different provisioner type!
typeof (window as any).playwright !== "undefined" ? "echo" : "terraform";
export const newTemplate = (formData: CreateTemplateData) => {
const { autostop_requirement_days_of_week, autostop_requirement_weeks } =
formData;
@ -56,10 +53,11 @@ export const getFormPermissions = (entitlements: Entitlements) => {
export const firstVersionFromFile = (
fileId: string,
variables: VariableValue[] | undefined,
) => {
provisionerType: ProvisionerType,
): CreateTemplateVersionRequest => {
return {
storage_method: "file" as const,
provisioner: provisioner,
provisioner: provisionerType,
user_variable_values: variables,
file_id: fileId,
tags: {},
@ -69,10 +67,11 @@ export const firstVersionFromFile = (
export const firstVersionFromExample = (
example: TemplateExample,
variables: VariableValue[] | undefined,
) => {
): CreateTemplateVersionRequest => {
return {
storage_method: "file" as const,
provisioner: provisioner,
// All starter templates are for the terraform provisioner type.
provisioner: "terraform",
user_variable_values: variables,
example_id: example.id,
tags: {},