Compare commits

..

1 Commits

Author SHA1 Message Date
DevelopmentCats d638371a85 feat: initial commit for restic 2025-10-20 13:58:22 -05:00
30 changed files with 2244 additions and 1436 deletions
-2
View File
@@ -5,8 +5,6 @@ Hashi = "Hashi"
HashiCorp = "HashiCorp"
mavrickrishi = "mavrickrishi" # Username
mavrick = "mavrick" # Username
inh = "inh" # Option in setpriv command
exportfs = "exportfs" # nfs related binary
[files]
extend-exclude = ["registry/coder/templates/aws-devcontainer/architecture.svg"] #False positive
-210
View File
@@ -1,210 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="300mm"
height="207mm"
viewBox="0 0 300 207"
version="1.1"
id="svg1"
inkscape:version="1.3.2 (091e20ef0f, 2023-11-25)"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<title
id="title1">copyparty_logo</title>
<defs
id="defs1">
<linearGradient
inkscape:collect="always"
id="linearGradient1">
<stop
style="stop-color:#ffcc55;stop-opacity:1"
offset="0"
id="stop1" />
<stop
style="stop-color:#ffcc00;stop-opacity:1"
offset="0.2"
id="stop2" />
<stop
style="stop-color:#ff8800;stop-opacity:1"
offset="1"
id="stop3" />
</linearGradient>
<linearGradient
inkscape:collect="always"
xlink:href="#linearGradient1"
id="linearGradient2"
x1="15"
y1="15"
x2="15"
y2="143"
gradientUnits="userSpaceOnUse" />
</defs>
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title>copyparty_logo</dc:title>
<dc:source>github.com/9001/copyparty</dc:source>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:groupmode="layer"
id="layer1"
inkscape:label="kassett">
<rect
style="fill:#333333"
id="rect1"
width="300"
height="205"
x="0"
y="0"
rx="12"
ry="12" />
<rect
style="fill:url(#linearGradient2)"
id="rect2"
width="270"
height="128"
x="15"
y="15"
rx="8"
ry="8" />
<rect
style="fill:#333333"
id="rect3"
width="172"
height="52"
x="64"
y="72"
rx="26"
ry="26" />
<circle
style="fill:#cccccc"
id="circle1"
cx="91"
cy="98"
r="18" />
<circle
style="fill:#cccccc"
id="circle2"
cx="209"
cy="98"
r="18" />
<path
style="fill:#737373;stroke-width:1px"
d="m 48,207 10,-39 c 1.79,-6.2 5.6,-7.8 12,-8 60,-1 100,-1 160,0 6.4,0.2 10,1.8 12,8 l 10,39 z"
id="path1"
sodipodi:nodetypes="ccccccc" />
</g>
<g
inkscape:groupmode="layer"
id="layer3"
inkscape:label="tekst"
style="display:none">
<text
xml:space="preserve"
style="font-size:38.8056px;line-height:1.25;font-family:Akbar;-inkscape-font-specification:Akbar;letter-spacing:3.70417px;word-spacing:0px;fill:#333333"
x="47.153069"
y="55.548954"
id="text1"><tspan
sodipodi:role="line"
id="tspan1"
x="47.153069"
y="55.548954"
style="-inkscape-font-specification:Akbar"
rotate="0 0">copyparty</tspan></text>
</g>
<g
inkscape:groupmode="layer"
id="layer4"
inkscape:label="stensatt">
<path
d="m 63.5,50.9 q -0.85,0.93 -4.73,2.3 -3.6,1.3 -4.4,1.3 -3.3,0 -5.1,-2.1 -1.75,-2 -1.75,-5.36 0,-4.6 3.76,-7.64 3.3,-2.7 7.3,-2.7 0.4,0 0.93,0.74 0.54,0.7 0.54,1.16 0,2.06 -2.2,2.7 -1.36,0.4 -4.04,1.16 -2.2,1.16 -2.2,4.4 0,3.2 2.9,3.2 0.85,0 0.85,0 0.54,0 1.44,-0.16 1.1,-0.23 2.9,-0.74 1.8,-0.54 2.13,-0.54 0.4,0 1.75,0.6 z"
style="fill:#333333"
id="path11" />
<path
d="m 87.6,45 q 0,4.2 -3.7,6.95 -3.2,2.3 -6.87,2.3 -3.4,0 -6,-2.6 -2.5,-2.6 -2.5,-6 0,-3.6 3.14,-6.64 3.2,-3 6.8,-3 3.5,0 6.3,2.76 2.83,2.76 2.83,6.25 z m -3.4,0.16 q 0,-2.25 -1.75,-3.7 -1.7,-1.5 -4,-1.5 -0.1,0 -1.6,1.6 -1.44,1.55 -2.44,1.55 -0.6,0 -0.8,-0.3 -1.16,2.3 -1.16,3 0,2.25 2.13,3.4 1.6,0.9 3.6,0.9 2,0 3.76,-1.1 2.25,-1.4 2.25,-3.84 z"
style="fill:#333333"
id="path12" />
<path
d="m 112.8,46.8 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2.1,0 -2.1,2.64 0,0.85 0.23,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.77,2.83 -1.44,0 -3,-0.85 -1.46,-9.5 -1.46,-12 0,-3.65 1.75,-8.1 2.37,-6.05 6.45,-6.05 3.7,0 7.3,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.33,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.38,1.24 0.43,0.8 0.85,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
style="fill:#333333"
id="path13" />
<path
d="m 133,40 q -2.1,4.1 -3.2,7 -0.1,0.3 -1.6,4.5 -0.4,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.3,2.64 -1.4,-0.2 -1.6,-1.6 0,-0.2 0,-0.5 0,-0.16 0.3,-1.5 1,-5.04 1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.5,-1.36 2.1,-1.36 0.4,0 1.1,0.6 0.6,0.6 0.7,1.1 0.8,6.2 4.9,11.1 1,-1.8 1.8,-4.04 0.5,-1.4 1.6,-4.15 1.9,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.9,0.3 1.3,2.8 z"
style="fill:#333333"
id="path14" />
<path
d="m 157.5,48 q 0,2.8 -1.9,4.4 -1.8,1.5 -4.7,1.5 -0.7,0 -2.7,-0.4 -1.9,-0.4 -2.6,-0.4 -2,0 -2,2.64 0,0.85 0.2,2.6 0.2,1.75 0.2,2.6 0,1.9 -0.7,2.83 -1.5,0 -3,-0.85 -1.5,-9.5 -1.5,-11.95 0,-3.65 1.8,-8.1 2.3,-6.05 6.4,-6.05 3.7,0 7.2,4.1 3.3,3.84 3.3,7.14 z m -3.8,0.2 q -0.6,-2.2 -2.6,-4.4 -2.3,-2.5 -4.3,-2.5 -1.3,0 -2.3,2.2 -0.9,1.8 -0.9,3.26 0,0.47 0.4,1.24 0.4,0.8 0.8,0.8 1.1,0 3.2,0.3 2.1,0.3 3.2,0.3 0.3,0 1.3,-0.4 1,-0.47 1.3,-0.74 z"
style="fill:#333333"
id="path15" />
<path
d="m 182,53.3 q 0,0.9 -0.6,1.5 -0.6,0.6 -1.4,0.6 -1.6,0 -3,-0.9 -1.4,-0.93 -2.1,-2.3 -0.7,-0.1 -1.5,0.85 -0.9,1.16 -1.1,1.24 -1.2,0.54 -3.9,0.54 -2.2,0 -3.9,-2.44 -1.5,-2.13 -1.5,-4 0,-3.4 3.4,-6.4 3.2,-2.9 6.7,-2.9 0.9,0 1.7,0.6 0.8,0.6 0.8,1.44 0,0.54 -0.4,1.1 2.4,0.9 2.4,2.83 0,0.35 -0.1,1.05 -0.1,0.7 -0.1,1.05 0,0.4 0.1,0.6 0.5,1.3 2.5,3.4 1.9,1.9 1.9,2.2 z m -8.1,-10.1 q -0.4,0 -1.1,-0.1 -0.8,-0.16 -1.1,-0.16 -1.3,0 -3.2,1.94 -1.9,1.94 -1.9,3.3 0,0.8 0.7,1.8 0.9,1.3 2.2,1.3 2.6,0 3.5,-2.9 0.5,-2.6 1,-5.16 z"
style="fill:#333333"
id="path16" />
<path
d="m 203.8,42.4 q -0.4,0.4 -1.5,0.4 -0.9,0 -2.5,-0.3 -1.7,-0.3 -2.5,-0.3 -4.7,0 -5.5,6.9 -0.3,3.1 -0.4,3.3 -0.4,1 -1.7,2.3 h -1.1 q -0.7,-1.2 -1.3,-4.1 -0.6,-2.76 -0.6,-4.27 0,-1.16 0.1,-1.5 0.2,-0.54 1,-0.54 0.3,0 0.6,0.3 0.4,0.3 0.4,0.3 1.9,-3.53 3.1,-4.6 1.8,-1.7 5.1,-1.7 1.4,0 3.6,0.9 2.8,1.16 3.3,2.8 z"
style="fill:#333333"
id="path17" />
<path
d="m 229.5,37.16 q 0.3,0.8 0.3,1.44 0,1.86 -2.4,1.86 -1,0 -3.5,-0.5 -2.5,-0.54 -3.4,-0.54 -1.3,0 -1.5,0.1 -0.4,0.2 -0.4,1.2 0,2.2 0.6,6.9 0.7,5.86 1.6,6.13 -0.4,0.35 -0.4,1.1 -1.2,0.7 -2.6,0.7 -1.4,0 -2,-3.9 -0.2,-1.36 -0.5,-7.76 -0.2,-4.6 -0.8,-5.5 -0.3,-0.47 -4.3,-0.35 -1,0 -1.6,0.1 -0.5,0 -0.3,0 -0.8,0 -1.2,-0.7 -0.5,-1.3 -0.5,-1.4 0,-1.44 4.1,-2 1.6,-0.16 4.7,-0.5 0,-0.85 -0.1,-2.56 0,-1.75 0,-2.6 0,-4.35 2.1,-4.35 0.5,0 1.1,0.6 0.6,0.6 0.6,1.1 v 7.9 q 1.1,1.2 5,1.7 3.9,0.5 5.3,1.86 z"
style="fill:#333333"
id="path18" />
<path
d="m 251.2,40.2 q -2,4.1 -3.2,7 -0.1,0.3 -1.5,4.5 -0.5,1.36 -1,4.2 -0.5,2.83 -1,4.2 -1,2.83 -2.4,2.64 -1.4,-0.2 -1.5,-1.6 -0.1,-0.2 -0.1,-0.5 0,-0.16 0.3,-1.5 1.1,-5.04 1.1,-6.44 0,-0.54 -0.1,-0.74 -1.4,-2.44 -4.1,-7.4 -2.7,-4.97 -2.4,-7.7 1.4,-1.36 2.1,-1.36 0.4,0 1,0.6 0.6,0.6 0.7,1.1 0.9,6.2 4.9,11.1 1,-1.8 1.9,-4.04 0.5,-1.4 1.6,-4.15 1.8,-4.46 3.4,-4.46 0.2,0 0.4,0.1 0.8,0.3 1.2,2.8 z"
style="fill:#333333"
id="path19" />
</g>
<g
inkscape:groupmode="layer"
id="layer5"
inkscape:label="tagger">
<g
id="g1">
<path
id="path4"
style="fill:#333333"
d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path5"
style="fill:#333333"
d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path6"
style="fill:#333333"
d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
</g>
<g
id="g2"
transform="rotate(30,150,318.19)">
<path
id="path7"
style="fill:#333333"
d="m 111.4,83.335 -9.526,5.5 2.5,4.33 9.526,-5.5 z m -33.775,19.5 -9.526,5.5 2.5,4.33 9.526,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path8"
style="fill:#333333"
d="M 88.5,73 V 84 h 5 V 73 Z m 0,39 v 11 h 5 V 112 Z"
sodipodi:nodetypes="cccccccccc" />
<path
id="path9"
style="fill:#333333"
d="m 68.1,87.665 9.526,5.5 2.5,-4.33 -9.526,-5.5 z m 33.775,19.5 9.527,5.5 2.5,-4.33 -9.527,-5.5 z"
sodipodi:nodetypes="cccccccccc" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 8.3 KiB

+590
View File
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 202 KiB

@@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.2"
version = "0.2.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
}
@@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.2"
version = "0.2.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
@@ -71,12 +71,12 @@ Customize tool permissions, MCP servers, and Copilot settings:
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.2"
version = "0.2.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
# Version pinning (defaults to "latest", use specific version if desired)
copilot_version = "0.0.334"
# Version pinning (defaults to "0.0.334", use "latest" for newest version)
copilot_version = "latest"
# Tool permissions
allow_tools = ["shell(git)", "shell(npm)", "write"]
@@ -142,7 +142,7 @@ variable "github_token" {
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.2"
version = "0.2.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/projects"
github_token = var.github_token
@@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This
```tf
module "copilot" {
source = "registry.coder.com/coder-labs/copilot/coder"
version = "0.2.2"
version = "0.2.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
report_tasks = false
+1 -1
View File
@@ -104,7 +104,7 @@ variable "agentapi_version" {
variable "copilot_version" {
type = string
description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'."
default = "latest"
default = "0.0.334"
}
variable "report_tasks" {
+6 -27
View File
@@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
@@ -32,29 +32,8 @@ module "claude-code" {
- You can get the API key from the [Anthropic Console](https://console.anthropic.com/dashboard).
- You can get the Session Token using the `claude setup-token` command. This is a long-lived authentication token (requires Claude subscription)
### Session Resumption Behavior
By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false`
## Examples
### Usage with Agent Boundaries
This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access.
```tf
module "claude-code" {
source = "dev.registry.coder.com/coder/claude-code/coder"
enable_boundary = true
boundary_version = "main"
boundary_log_dir = "/tmp/boundary_logs"
boundary_log_level = "WARN"
boundary_additional_allowed_urls = ["GET *google.com"]
boundary_proxy_port = "8087"
version = "3.3.0"
}
```
### Usage with Tasks and Advanced Configuration
This example shows how to configure the Claude Code module with an AI prompt, API key shared by all users of the template, and other custom settings.
@@ -70,7 +49,7 @@ data "coder_parameter" "ai_prompt" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
@@ -106,7 +85,7 @@ Run and configure Claude Code as a standalone CLI in your workspace.
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder"
install_claude_code = true
@@ -129,7 +108,7 @@ variable "claude_code_oauth_token" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
@@ -202,7 +181,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -259,7 +238,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "3.3.0"
version = "3.1.1"
agent_id = coder_agent.example.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -167,7 +167,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
permission_mode: mode,
ai_prompt: "test prompt",
task_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -185,7 +185,7 @@ describe("claude-code", async () => {
const { id } = await setup({
moduleVariables: {
model: model,
ai_prompt: "test prompt",
task_prompt: "test prompt",
},
});
await execModuleScript(id);
@@ -198,24 +198,13 @@ describe("claude-code", async () => {
expect(startLog.stdout).toContain(`--model ${model}`);
});
test("claude-continue-resume-existing-session", async () => {
test("claude-continue-previous-conversation", async () => {
const { id } = await setup({
moduleVariables: {
continue: "true",
ai_prompt: "test prompt",
task_prompt: "test prompt",
},
});
// Create a mock session file with the predefined task session ID
const taskSessionId = "cd32e253-ca16-4fd3-9825-d837e74ae3c2";
const sessionDir = `/home/coder/.claude/projects/-home-coder-project`;
await execContainer(id, ["mkdir", "-p", sessionDir]);
await execContainer(id, [
"bash",
"-c",
`touch ${sessionDir}/session-${taskSessionId}.jsonl`,
]);
await execModuleScript(id);
const startLog = await execContainer(id, [
@@ -223,9 +212,7 @@ describe("claude-code", async () => {
"-c",
"cat /home/coder/.claude-module/agentapi-start.log",
]);
expect(startLog.stdout).toContain("--resume");
expect(startLog.stdout).toContain(taskSessionId);
expect(startLog.stdout).toContain("Resuming existing task session");
expect(startLog.stdout).toContain("--continue");
});
test("pre-post-install-scripts", async () => {
+2 -61
View File
@@ -134,8 +134,8 @@ variable "resume_session_id" {
variable "continue" {
type = bool
description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)."
default = true
description = "Load the most recent conversation in the current directory. Task will fail in a new workspace with no conversation/session to continue"
default = false
}
variable "dangerously_skip_permissions" {
@@ -192,54 +192,6 @@ variable "claude_md_path" {
default = "$HOME/.claude/CLAUDE.md"
}
variable "enable_boundary" {
type = bool
description = "Whether to enable coder boundary for network filtering"
default = false
}
variable "boundary_version" {
type = string
description = "Boundary version, valid git reference should be provided (tag, commit, branch)"
default = "main"
}
variable "boundary_log_dir" {
type = string
description = "Directory for boundary logs"
default = "/tmp/boundary_logs"
}
variable "boundary_log_level" {
type = string
description = "Log level for boundary process"
default = "WARN"
}
variable "boundary_additional_allowed_urls" {
type = list(string)
description = "Additional URLs to allow through boundary (in addition to default allowed URLs)"
default = []
}
variable "boundary_proxy_port" {
type = string
description = "Port for HTTP Proxy used by Boundary"
default = "8087"
}
variable "enable_boundary_pprof" {
type = bool
description = "Whether to enable coder boundary pprof server"
default = false
}
variable "boundary_pprof_port" {
type = string
description = "Port for pprof server used by Boundary"
default = "6067"
}
resource "coder_env" "claude_code_md_path" {
count = var.claude_md_path == "" ? 0 : 1
@@ -277,8 +229,6 @@ locals {
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".claude-module"
remove_last_session_id_script_b64 = base64encode(file("${path.module}/scripts/remove-last-session-id.sh"))
# Extract hostname from access_url for boundary --allow flag
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
# Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT
@@ -349,15 +299,6 @@ module "agentapi" {
ARG_PERMISSION_MODE='${var.permission_mode}' \
ARG_WORKDIR='${local.workdir}' \
ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \
ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \
ARG_BOUNDARY_VERSION='${var.boundary_version}' \
ARG_BOUNDARY_LOG_DIR='${var.boundary_log_dir}' \
ARG_BOUNDARY_LOG_LEVEL='${var.boundary_log_level}' \
ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS='${join(" ", var.boundary_additional_allowed_urls)}' \
ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \
ARG_ENABLE_BOUNDARY_PPROF='${var.enable_boundary_pprof}' \
ARG_BOUNDARY_PPROF_PORT='${var.boundary_pprof_port}' \
ARG_CODER_HOST='${local.coder_host}' \
/tmp/start.sh
EOT
@@ -188,32 +188,6 @@ run "test_claude_code_permission_mode_validation" {
}
}
run "test_claude_code_with_boundary" {
command = plan
variables {
agent_id = "test-agent-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_log_dir = "/tmp/test-boundary-logs"
}
assert {
condition = var.enable_boundary == true
error_message = "Boundary should be enabled"
}
assert {
condition = var.boundary_log_dir == "/tmp/test-boundary-logs"
error_message = "Boundary log dir should be set correctly"
}
assert {
condition = local.coder_host != ""
error_message = "Coder host should be extracted from access URL"
}
}
run "test_claude_code_system_prompt" {
command = plan
@@ -293,4 +267,4 @@ run "test_claude_report_tasks_disabled" {
condition = endswith(trimspace(coder_env.claude_code_system_prompt.value), "</system>")
error_message = "System prompt should end with </system>"
}
}
}
@@ -17,14 +17,6 @@ ARG_DANGEROUSLY_SKIP_PERMISSIONS=${ARG_DANGEROUSLY_SKIP_PERMISSIONS:-}
ARG_PERMISSION_MODE=${ARG_PERMISSION_MODE:-}
ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"}
ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d)
ARG_ENABLE_BOUNDARY=${ARG_ENABLE_BOUNDARY:-false}
ARG_BOUNDARY_VERSION=${ARG_BOUNDARY_VERSION:-"main"}
ARG_BOUNDARY_LOG_DIR=${ARG_BOUNDARY_LOG_DIR:-"/tmp/boundary_logs"}
ARG_BOUNDARY_LOG_LEVEL=${ARG_BOUNDARY_LOG_LEVEL:-"WARN"}
ARG_BOUNDARY_PROXY_PORT=${ARG_BOUNDARY_PROXY_PORT:-"8087"}
ARG_ENABLE_BOUNDARY_PPROF=${ARG_ENABLE_BOUNDARY_PPROF:-false}
ARG_BOUNDARY_PPROF_PORT=${ARG_BOUNDARY_PPROF_PORT:-"6067"}
ARG_CODER_HOST=${ARG_CODER_HOST:-}
echo "--------------------------------"
@@ -35,12 +27,6 @@ printf "ARG_DANGEROUSLY_SKIP_PERMISSIONS: %s\n" "$ARG_DANGEROUSLY_SKIP_PERMISSIO
printf "ARG_PERMISSION_MODE: %s\n" "$ARG_PERMISSION_MODE"
printf "ARG_AI_PROMPT: %s\n" "$ARG_AI_PROMPT"
printf "ARG_WORKDIR: %s\n" "$ARG_WORKDIR"
printf "ARG_ENABLE_BOUNDARY: %s\n" "$ARG_ENABLE_BOUNDARY"
printf "ARG_BOUNDARY_VERSION: %s\n" "$ARG_BOUNDARY_VERSION"
printf "ARG_BOUNDARY_LOG_DIR: %s\n" "$ARG_BOUNDARY_LOG_DIR"
printf "ARG_BOUNDARY_LOG_LEVEL: %s\n" "$ARG_BOUNDARY_LOG_LEVEL"
printf "ARG_BOUNDARY_PROXY_PORT: %s\n" "$ARG_BOUNDARY_PROXY_PORT"
printf "ARG_CODER_HOST: %s\n" "$ARG_CODER_HOST"
echo "--------------------------------"
@@ -49,14 +35,6 @@ echo "--------------------------------"
# avoid exiting if the script fails
bash "/tmp/remove-last-session-id.sh" "$(pwd)" 2> /dev/null || true
function install_boundary() {
# Install boundary from public github repo
git clone https://github.com/coder/boundary
cd boundary
git checkout $ARG_BOUNDARY_VERSION
go install ./cmd/...
}
function validate_claude_installation() {
if command_exists claude; then
printf "Claude Code is installed\n"
@@ -66,119 +44,41 @@ function validate_claude_installation() {
fi
}
TASK_SESSION_ID="cd32e253-ca16-4fd3-9825-d837e74ae3c2"
task_session_exists() {
if find "$HOME/.claude" -type f -name "*${TASK_SESSION_ID}*" 2> /dev/null | grep -q .; then
return 0
else
return 1
fi
}
ARGS=()
function start_agentapi() {
mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR"
function build_claude_args() {
if [ -n "$ARG_MODEL" ]; then
ARGS+=(--model "$ARG_MODEL")
fi
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
fi
if [ "$ARG_CONTINUE" = "true" ]; then
ARGS+=(--continue)
fi
if [ -n "$ARG_PERMISSION_MODE" ]; then
ARGS+=(--permission-mode "$ARG_PERMISSION_MODE")
fi
if [ -n "$ARG_RESUME_SESSION_ID" ]; then
echo "Using explicit resume_session_id: $ARG_RESUME_SESSION_ID"
ARGS+=(--resume "$ARG_RESUME_SESSION_ID")
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
}
function start_agentapi() {
mkdir -p "$ARG_WORKDIR"
cd "$ARG_WORKDIR"
if [ -n "$ARG_AI_PROMPT" ]; then
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
else
if [ -n "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" ]; then
ARGS+=(--dangerously-skip-permissions)
fi
elif [ "$ARG_CONTINUE" = "true" ]; then
if task_session_exists; then
echo "Task session detected (ID: $TASK_SESSION_ID)"
ARGS+=(--resume "$TASK_SESSION_ID")
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
ARGS+=(--dangerously-skip-permissions)
fi
echo "Resuming existing task session"
else
echo "No existing task session found"
ARGS+=(--session-id "$TASK_SESSION_ID")
if [ -n "$ARG_AI_PROMPT" ]; then
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
echo "Starting new task session with prompt"
else
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
ARGS+=(--dangerously-skip-permissions)
fi
echo "Starting new task session"
fi
fi
else
echo "Continue disabled, starting fresh session"
if [ -n "$ARG_AI_PROMPT" ]; then
ARGS+=(--dangerously-skip-permissions "$ARG_AI_PROMPT")
echo "Starting new session with prompt"
else
if [ "$ARG_DANGEROUSLY_SKIP_PERMISSIONS" = "true" ]; then
ARGS+=(--dangerously-skip-permissions)
fi
echo "Starting claude code session"
fi
fi
printf "Running claude code with args: %s\n" "$(printf '%q ' "${ARGS[@]}")"
if [ "${ARG_ENABLE_BOUNDARY:-false}" = "true" ]; then
install_boundary
mkdir -p "$ARG_BOUNDARY_LOG_DIR"
printf "Starting with coder boundary enabled\n"
# Build boundary args with conditional --unprivileged flag
BOUNDARY_ARGS=(--log-dir "$ARG_BOUNDARY_LOG_DIR")
# Add default allowed URLs
BOUNDARY_ARGS+=(--allow "*anthropic.com" --allow "registry.npmjs.org" --allow "*sentry.io" --allow "claude.ai" --allow "$ARG_CODER_HOST")
# Add any additional allowed URLs from the variable
if [ -n "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS" ]; then
IFS=' ' read -ra ADDITIONAL_URLS <<< "$ARG_BOUNDARY_ADDITIONAL_ALLOWED_URLS"
for url in "${ADDITIONAL_URLS[@]}"; do
BOUNDARY_ARGS+=(--allow "$url")
done
fi
# Set HTTP Proxy port used by Boundary
BOUNDARY_ARGS+=(--proxy-port $ARG_BOUNDARY_PROXY_PORT)
# Set log level for boundary
BOUNDARY_ARGS+=(--log-level $ARG_BOUNDARY_LOG_LEVEL)
if [ "${ARG_ENABLE_BOUNDARY_PPROF:-false}" = "true" ]; then
# Enable boundary pprof server on specified port
BOUNDARY_ARGS+=(--pprof)
BOUNDARY_ARGS+=(--pprof-port ${ARG_BOUNDARY_PPROF_PORT})
fi
# Remove --dangerously-skip-permissions from ARGS when using boundary (it doesn't work with elevated permissions)
# Create a new array without the dangerous permissions flag
CLAUDE_ARGS=()
for arg in "${ARGS[@]}"; do
if [ "$arg" != "--dangerously-skip-permissions" ]; then
CLAUDE_ARGS+=("$arg")
fi
done
agentapi server --allowed-hosts="*" --type claude --term-width 67 --term-height 1190 -- \
sudo -E env PATH=$PATH setpriv --inh-caps=+net_admin --ambient-caps=+net_admin --bounding-set=+net_admin boundary "${BOUNDARY_ARGS[@]}" -- \
claude "${CLAUDE_ARGS[@]}"
else
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
fi
agentapi server --type claude --term-width 67 --term-height 1190 -- claude "${ARGS[@]}"
}
validate_claude_installation
build_claude_args
start_agentapi
+523
View File
@@ -0,0 +1,523 @@
---
display_name: Restic Backup
description: Cloud-backed ephemeral workspaces with automatic backup on stop and restore on start using Restic
icon: ../../../../.icons/restic.svg
verified: false
tags: [backup, restore, cloud, restic, s3, b2]
---
# Restic Backup
Automatic cloud backups for Coder workspaces. Backs up on stop, restores on start.
## Features
- Auto backup/restore on workspace stop/start
- Works with S3, B2, Azure, GCS, SFTP, local storage
- Encrypted and deduplicated
- Workspace-aware tagging for easy browsing
- Configurable retention policies
- Clone backups between workspaces
## Quick Start
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/my-workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
## How It Works
1. Workspace stops → automatic backup to cloud
2. Workspace starts → automatic restore from backup
3. Backups are tagged with `workspace-id`, `workspace-owner`, `workspace-name`
4. Auto-restore uses `workspace-id` to find the correct backup
5. Manually restore any backup using `snapshot_id`
## Storage Backend Configuration
### AWS S3
[Official Restic S3 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#amazon-s3)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/my-bucket/workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
AWS_DEFAULT_REGION = "us-east-1"
}
}
```
### Backblaze B2 (Cost-Effective)
[Official Restic B2 Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#backblaze-b2)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "b2:my-bucket:workspace-backups"
password = var.restic_password
env = {
B2_ACCOUNT_ID = var.b2_account_id
B2_ACCOUNT_KEY = var.b2_account_key
}
}
```
### Azure Blob Storage
[Official Restic Azure Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#microsoft-azure-blob-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "azure:container-name:/workspace-backups"
password = var.restic_password
env = {
AZURE_ACCOUNT_NAME = var.azure_account_name
AZURE_ACCOUNT_KEY = var.azure_account_key
}
}
```
### Google Cloud Storage
[Official Restic GCS Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#google-cloud-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "gs:my-bucket:/workspace-backups"
password = var.restic_password
env = {
GOOGLE_PROJECT_ID = var.gcp_project_id
GOOGLE_APPLICATION_CREDENTIALS = "/path/to/service-account.json"
}
}
```
### MinIO or S3-Compatible Storage
[Official Restic Minio Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#minio-server) | [S3-Compatible](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#s3-compatible-storage)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:http://minio.company.com:9000/workspace-backups"
password = var.restic_password
env = {
AWS_ACCESS_KEY_ID = var.minio_access_key
AWS_SECRET_ACCESS_KEY = var.minio_secret_key
}
}
```
### SFTP
[Official Restic SFTP Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#sftp)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "sftp:user@backup-server.com:/backups/restic"
password = var.restic_password
# SSH key should be at ~/.ssh/id_rsa
# Or configure custom SSH command:
env = {
RESTIC_SFTP_COMMAND = "ssh user@host -i /path/to/key -s sftp"
}
}
```
### Local Directory (Testing)
[Official Restic Local Documentation](https://restic.readthedocs.io/en/stable/030_preparing_a_new_repo.html#local)
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "/backup/restic-repo"
password = var.restic_password
}
```
**Note:** Use persistent storage (Docker volume, PV) for local repositories.
## Advanced Configuration
### Selective Backup Paths
Only backup specific directories:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
backup_paths = [
"/home/coder/projects",
"/home/coder/.config",
"/home/coder/data",
]
exclude_patterns = [
"**/.git",
"**/node_modules",
"**/__pycache__",
"**/target",
"**/.venv",
"**/tmp",
]
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Periodic Backups While Running
Backup every N minutes while workspace is active:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "b2:workspace-backups"
password = var.restic_password
# Backup every 30 minutes while workspace is running
backup_interval_minutes = 30
env = {
B2_ACCOUNT_ID = var.b2_account_id
B2_ACCOUNT_KEY = var.b2_account_key
}
}
```
### Custom Stop Script
Run cleanup before backup:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
custom_stop_script = <<-EOF
#!/bin/bash
echo "Cleaning up before backup..."
rm -rf /tmp/*
docker system prune -f
find /home/coder -name "*.log" -delete
EOF
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Clone Another Workspace's Backup
Restore from a specific snapshot:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
# Restore from specific snapshot (find ID using: restic snapshots)
restore_on_start = true
snapshot_id = "abc123def" # The snapshot ID to restore
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
To find snapshot IDs from another workspace:
```bash
# List all snapshots grouped by workspace
restic snapshots --group-by tags
# Or filter by specific workspace
restic snapshots --tag workspace-owner:john --tag workspace-name:dev-workspace
```
### Custom Retention Policies
Control how many backups to keep:
```tf
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = var.restic_password
# Keep last 10 backups
retention_keep_last = 10
# Keep daily backups for 14 days
retention_keep_daily = 14
# Keep weekly backups for 8 weeks
retention_keep_weekly = 8
# Keep monthly backups for 6 months
retention_keep_monthly = 6
# Apply retention automatically
auto_forget = true
# Don't prune on stop (too slow)
auto_prune = false
env = {
AWS_ACCESS_KEY_ID = var.aws_access_key
AWS_SECRET_ACCESS_KEY = var.aws_secret_key
}
}
```
### Using HCP Vault Secrets
Store credentials securely:
```tf
module "vault_secrets" {
source = "registry.coder.com/coder/hcp-vault-secrets/coder"
version = "1.0.34"
agent_id = coder_agent.main.id
app_name = "workspace-backups"
project_id = var.hcp_project_id
secrets = ["RESTIC_PASSWORD", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY"]
}
module "restic" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/restic/coder"
version = "1.0.0"
agent_id = coder_agent.main.id
repository = "s3:s3.amazonaws.com/backups"
password = "" # Will use RESTIC_PASSWORD from vault
depends_on = [module.vault_secrets]
}
```
## Manual Operations
### Trigger Manual Backup
Click the **"Backup Now"** button in the Coder UI, or run from terminal:
```bash
restic-backup --tag manual-backup
```
### List Your Workspace's Backups
```bash
restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID
```
Or view all snapshots:
```bash
restic snapshots
```
### List All Workspace Backups in Repository
```bash
restic snapshots --group-by tags
```
This shows snapshots grouped by workspace, making it easy to see all workspace backups in the repository.
### Restore Specific Snapshot
```bash
# List snapshots for this workspace
restic snapshots --tag workspace-id:$RESTIC_WORKSPACE_ID
# Restore to temporary location for inspection
restic restore /tmp/restore < snapshot-id > --target
# Or restore to original location
restic restore / < snapshot-id > --target
```
### Check Repository Health
```bash
restic check
```
### Manual Cleanup
```bash
# Remove old snapshots for this workspace
restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 3
# Reclaim space (removes unreferenced data)
restic prune
```
## Important Considerations
### Stop Backup Limitations
> **Warning**: The `backup_on_stop` feature may not work on all template types if the agent is terminated before backup completes. See [coder/coder#6174](https://github.com/coder/coder/issues/6174) for details.
**Recommendations**:
- Test stop backups with your specific template
- Keep backups fast (use selective paths and exclusions)
- Use `backup_interval_minutes` for important data
- Set `auto_prune = false` for stop backups (prune is slow)
### Repository Organization
**Single Shared Repository** (Recommended):
- All workspaces share one repository
- Backups are tagged with workspace metadata
- Deduplication saves space
- Easy credential management
**Per-Workspace Repositories**:
- Each workspace uses separate repository
- More isolation but more complex
- No cross-workspace restore
### Security
- Repository password encrypts ALL backups
- Use Coder parameters or external secrets for credentials
- Backend credentials should have minimal permissions
- Consider separate repositories for different teams
### Performance Tips
- **Use exclusions**: Skip `.git`, `node_modules`, caches
- **Selective paths**: Only backup what you need
- **Interval backups**: Balance frequency vs performance
- **Retention policies**: Keep low retention to save storage costs
- **Prune manually**: Don't enable `auto_prune` on stop (too slow)
## Troubleshooting
### Backup Fails on Stop
The workspace might be terminating before backup completes. Try:
- Reducing backup size with selective paths
- Using interval backups instead
- Testing with a local repository first
### Restore Blocks Login Too Long
- Reduce restore size with selective backup paths
- Set `start_blocks_login = false` to allow login during restore
- Use faster storage backend
### Repository Not Found
Ensure:
- Repository URL is correct
- Backend credentials are valid
- Network connectivity to storage backend
- Repository has been initialized (`auto_init_repo = true`)
### Permission Denied
Check:
- Backend credentials have write permissions
- Local directory (if used) is writable
- SSH key (for SFTP) is accessible
### Out of Storage Space
Run cleanup:
```bash
restic forget --tag workspace-id:$RESTIC_WORKSPACE_ID --keep-last 2
restic prune
```
## Links
- [Restic Documentation](https://restic.readthedocs.io/)
- [Restic GitHub](https://github.com/restic/restic)
- [Coder Documentation](https://coder.com/docs)
@@ -0,0 +1,75 @@
import { describe, expect, it } from "bun:test";
import {
executeScriptInContainer,
runTerraformApply,
runTerraformInit,
testRequiredVariables,
} from "~test";
describe("restic", async () => {
await runTerraformInit(import.meta.dir);
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
repository: "s3:s3.amazonaws.com/test-bucket",
password: "test-password",
});
it("installs restic successfully", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
repository: "/tmp/restic-repo",
password: "test-password",
install_restic: "true",
auto_init_repo: "false",
restore_on_start: "false",
});
const output = await executeScriptInContainer(
state,
"alpine",
"sh",
"apk add --no-cache curl bzip2",
);
if (output.exitCode !== 0) {
console.log("Exit code:", output.exitCode);
console.log("STDOUT:", output.stdout.join("\n"));
console.log("STDERR:", output.stderr.join("\n"));
}
expect(output.exitCode).toBe(0);
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Restic Backup Module Setup");
expect(stdout).toContain("Installing Restic...");
expect(stdout).toContain("Detected OS: linux");
expect(stdout).toContain("Architecture:");
expect(stdout).toContain("Fetching latest version");
expect(stdout).toContain("Version:");
expect(stdout).toContain("Downloading Restic");
expect(stdout).toContain("Restic installed:");
expect(stdout).toContain("Restic verified:");
expect(stdout).toContain("restic");
expect(stdout).toContain("Restic setup complete");
});
it("creates backup helper script in workspace", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent",
repository: "/tmp/restic-repo",
password: "test-password",
install_restic: "false",
auto_init_repo: "false",
restore_on_start: "false",
});
const output = await executeScriptInContainer(state, "alpine");
const stdout = output.stdout.join("\n");
expect(stdout).toContain("Installing backup helper script");
expect(stdout).toContain("Backup helper installed:");
expect(stdout).toContain("/restic-backup");
expect(stdout).toContain("Backup helper verified as executable");
});
});
+271
View File
@@ -0,0 +1,271 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 0.12"
}
}
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "repository" {
type = string
description = "Restic repository location (e.g., 's3:s3.amazonaws.com/bucket', 'b2:bucket-name', '/local/path')."
}
variable "password" {
type = string
description = "Password for encrypting the Restic repository. Keep this secure!"
sensitive = true
}
variable "install_restic" {
type = bool
description = "Whether to install Restic binary."
default = true
}
variable "restic_version" {
type = string
description = "Version of Restic to install (e.g., '0.16.4' or 'latest')."
default = "latest"
}
variable "backup_paths" {
type = list(string)
description = "List of paths to backup. Can be absolute or relative to 'directory'."
default = ["/home/coder"]
}
variable "exclude_patterns" {
type = list(string)
description = "Patterns to exclude from backup (e.g., ['**/.git', '**/node_modules'])."
default = []
}
variable "backup_tags" {
type = list(string)
description = "Additional tags to apply to all snapshots."
default = []
}
variable "directory" {
type = string
description = "Working directory for backup operations."
default = "~"
}
variable "backup_on_stop" {
type = bool
description = "Whether to automatically backup when workspace stops."
default = true
}
variable "backup_interval_minutes" {
type = number
description = "Backup every N minutes while workspace is running (0 = disabled)."
default = 0
}
variable "restore_on_start" {
type = bool
description = "Whether to restore from backup when workspace starts."
default = true
}
variable "snapshot_id" {
type = string
description = "Specific snapshot ID to restore. If empty and restore_on_start is true, restores latest backup of this workspace. If set, restores that specific snapshot (useful for cloning workspaces)."
default = ""
}
variable "restore_target" {
type = string
description = "Target directory for restore ('/' restores to original paths)."
default = "/"
}
variable "start_blocks_login" {
type = bool
description = "Whether to block login until restore completes."
default = true
}
variable "custom_stop_script" {
type = string
description = "Custom script to run before stop backup."
default = ""
}
variable "retention_keep_last" {
type = number
description = "Keep last N snapshots per workspace."
default = 10
}
variable "retention_keep_daily" {
type = number
description = "Keep daily snapshots for N days."
default = 14
}
variable "retention_keep_weekly" {
type = number
description = "Keep weekly snapshots for N weeks."
default = 8
}
variable "retention_keep_monthly" {
type = number
description = "Keep monthly snapshots for N months."
default = 6
}
variable "auto_forget" {
type = bool
description = "Apply retention policies automatically after backup."
default = false
}
variable "auto_prune" {
type = bool
description = "Run prune after forget to reclaim space (slower but frees storage)."
default = false
}
variable "auto_init_repo" {
type = bool
description = "Automatically initialize repository if it doesn't exist."
default = true
}
variable "env" {
type = map(string)
description = "Environment variables for backend configuration (e.g., AWS_ACCESS_KEY_ID, B2_ACCOUNT_KEY). See README for backend-specific examples."
default = {}
sensitive = true
}
variable "icon" {
type = string
description = "Icon to use for Restic apps."
default = "/icon/restic.svg"
}
variable "order" {
type = number
description = "Order of apps in UI."
default = null
}
variable "group" {
type = string
description = "Group name for apps."
default = null
}
resource "coder_env" "restic_repository" {
agent_id = var.agent_id
name = "RESTIC_REPOSITORY"
value = var.repository
}
resource "coder_env" "restic_password" {
agent_id = var.agent_id
name = "RESTIC_PASSWORD"
value = var.password
}
resource "coder_env" "backend_env" {
for_each = nonsensitive(var.env)
agent_id = var.agent_id
name = each.key
value = each.value
}
resource "coder_env" "workspace_owner" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_OWNER"
value = data.coder_workspace_owner.me.name
}
resource "coder_env" "workspace_name" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_NAME"
value = data.coder_workspace.me.name
}
resource "coder_env" "workspace_id" {
agent_id = var.agent_id
name = "RESTIC_WORKSPACE_ID"
value = data.coder_workspace.me.id
}
resource "coder_script" "install_and_restore" {
agent_id = var.agent_id
display_name = "Restic Setup"
icon = var.icon
run_on_start = true
start_blocks_login = var.restore_on_start && var.start_blocks_login
script = templatefile("${path.module}/scripts/run.sh", {
INSTALL_RESTIC = var.install_restic
RESTIC_VERSION = var.restic_version
AUTO_INIT = var.auto_init_repo
RESTORE_ON_START = var.restore_on_start
SNAPSHOT_ID = var.snapshot_id
RESTORE_TARGET = var.restore_target
BACKUP_INTERVAL = var.backup_interval_minutes
BACKUP_PATHS = jsonencode(var.backup_paths)
EXCLUDE_PATTERNS = jsonencode(var.exclude_patterns)
BACKUP_TAGS = jsonencode(var.backup_tags)
DIRECTORY = var.directory
RETENTION_LAST = var.retention_keep_last
RETENTION_DAILY = var.retention_keep_daily
RETENTION_WEEKLY = var.retention_keep_weekly
RETENTION_MONTHLY = var.retention_keep_monthly
AUTO_FORGET = var.auto_forget
AUTO_PRUNE = var.auto_prune
BACKUP_SCRIPT_B64 = base64encode(file("${path.module}/scripts/backup.sh"))
})
}
resource "coder_script" "stop_backup" {
count = var.backup_on_stop ? 1 : 0
agent_id = var.agent_id
display_name = "Restic Backup"
icon = var.icon
run_on_stop = true
start_blocks_login = false
script = <<-EOT
#!/usr/bin/env bash
set -euo pipefail
${var.custom_stop_script}
"$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "stop-backup"
EOT
}
resource "coder_app" "restic_backup" {
agent_id = var.agent_id
slug = "restic-backup"
display_name = "Backup Now"
icon = var.icon
order = var.order
group = var.group
command = "$CODER_SCRIPT_BIN_DIR/restic-backup --tag manual-backup"
}
@@ -0,0 +1,333 @@
run "required_variables" {
command = plan
variables {
agent_id = "test-agent"
repository = "s3:s3.amazonaws.com/test-bucket"
password = "test-password"
}
}
run "stop_backup_script_created_when_enabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = true
}
assert {
condition = coder_script.stop_backup[0].run_on_stop == true
error_message = "Stop backup script should have run_on_stop enabled"
}
assert {
condition = coder_script.stop_backup[0].agent_id == "test-agent"
error_message = "Stop backup script should use correct agent_id"
}
}
run "stop_backup_script_not_created_when_disabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = false
}
assert {
condition = length(coder_script.stop_backup) == 0
error_message = "Stop backup script should not be created when backup_on_stop is false"
}
}
run "restore_blocks_login_by_default" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
}
assert {
condition = coder_script.install_and_restore.start_blocks_login == true
error_message = "Install script should block login when restore_on_start and start_blocks_login are true"
}
}
run "restore_does_not_block_login_when_disabled" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
start_blocks_login = false
}
assert {
condition = coder_script.install_and_restore.start_blocks_login == false
error_message = "Install script should not block login when start_blocks_login is false"
}
}
run "workspace_metadata_env_vars_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = coder_env.workspace_owner.name == "RESTIC_WORKSPACE_OWNER"
error_message = "Workspace owner env var should be RESTIC_WORKSPACE_OWNER"
}
assert {
condition = coder_env.workspace_name.name == "RESTIC_WORKSPACE_NAME"
error_message = "Workspace name env var should be RESTIC_WORKSPACE_NAME"
}
assert {
condition = coder_env.workspace_id.name == "RESTIC_WORKSPACE_ID"
error_message = "Workspace ID env var should be RESTIC_WORKSPACE_ID"
}
}
run "core_env_vars_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "s3:s3.amazonaws.com/bucket"
password = "secure-password"
}
assert {
condition = coder_env.restic_repository.name == "RESTIC_REPOSITORY"
error_message = "Repository env var should be RESTIC_REPOSITORY"
}
assert {
condition = coder_env.restic_repository.value == "s3:s3.amazonaws.com/bucket"
error_message = "Repository env var should match input"
}
assert {
condition = coder_env.restic_password.name == "RESTIC_PASSWORD"
error_message = "Password env var should be RESTIC_PASSWORD"
}
}
run "safe_retention_defaults" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
# Verify auto_forget is false by default (safe)
assert {
condition = var.auto_forget == false
error_message = "auto_forget should be false by default for safety"
}
# Verify reasonable retention defaults
assert {
condition = var.retention_keep_last == 10
error_message = "Default retention_keep_last should be 10"
}
assert {
condition = var.retention_keep_daily == 14
error_message = "Default retention_keep_daily should be 14"
}
}
run "manual_backup_app_created" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = coder_app.restic_backup.slug == "restic-backup"
error_message = "Backup app should have slug restic-backup"
}
assert {
condition = coder_app.restic_backup.display_name == "Backup Now"
error_message = "Backup app should display 'Backup Now'"
}
assert {
condition = can(regex("restic-backup", coder_app.restic_backup.command))
error_message = "Backup app command should call restic-backup helper"
}
}
run "install_restic_enabled_in_script" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
install_restic = true
}
assert {
condition = can(regex("INSTALL_RESTIC=\"true\"", coder_script.install_and_restore.script))
error_message = "Script should have INSTALL_RESTIC set to true"
}
}
run "install_restic_disabled_in_script" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
install_restic = false
}
assert {
condition = can(regex("INSTALL_RESTIC=\"false\"", coder_script.install_and_restore.script))
error_message = "Script should have INSTALL_RESTIC set to false"
}
}
run "auto_init_repo_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
auto_init_repo = false
}
assert {
condition = can(regex("AUTO_INIT=\"false\"", coder_script.install_and_restore.script))
error_message = "Script should have AUTO_INIT set to false"
}
}
run "restore_on_start_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
restore_on_start = true
snapshot_id = "abc123"
}
assert {
condition = can(regex("RESTORE_ON_START=\"true\"", coder_script.install_and_restore.script))
error_message = "Script should have RESTORE_ON_START set to true"
}
assert {
condition = can(regex("SNAPSHOT_ID=\"abc123\"", coder_script.install_and_restore.script))
error_message = "Script should have SNAPSHOT_ID set to abc123"
}
}
run "interval_backup_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_interval_minutes = 30
}
assert {
condition = can(regex("BACKUP_INTERVAL=\"30\"", coder_script.install_and_restore.script))
error_message = "Script should have BACKUP_INTERVAL set to 30"
}
}
run "interval_backup_disabled_by_default" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
}
assert {
condition = can(regex("BACKUP_INTERVAL=\"0\"", coder_script.install_and_restore.script))
error_message = "Script should have BACKUP_INTERVAL set to 0 by default"
}
}
run "backup_paths_and_exclusions_configuration" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_paths = ["/home/coder", "/workspace"]
exclude_patterns = ["*.log", "node_modules"]
backup_tags = ["production", "daily"]
}
assert {
condition = can(regex("/home/coder", coder_script.install_and_restore.script))
error_message = "Script should contain backup path /home/coder"
}
assert {
condition = can(regex("/workspace", coder_script.install_and_restore.script))
error_message = "Script should contain backup path /workspace"
}
assert {
condition = can(regex("\\*.log", coder_script.install_and_restore.script))
error_message = "Script should contain exclude pattern *.log"
}
assert {
condition = can(regex("production", coder_script.install_and_restore.script))
error_message = "Script should contain backup tag production"
}
}
run "custom_stop_script_included" {
command = plan
variables {
agent_id = "test-agent"
repository = "/tmp/restic-repo"
password = "test-password"
backup_on_stop = true
custom_stop_script = "echo 'Pre-backup cleanup'"
}
assert {
condition = can(regex("echo 'Pre-backup cleanup'", coder_script.stop_backup[0].script))
error_message = "Stop script should contain custom stop script"
}
}
@@ -0,0 +1,104 @@
#!/usr/bin/env bash
set -euo pipefail
CONF_FILE="$CODER_SCRIPT_DATA_DIR/restic-backup.conf"
if [ -f "$CONF_FILE" ]; then
# shellcheck source=/dev/null
source "$CONF_FILE"
else
echo "Error: Configuration file not found: $CONF_FILE" >&2
exit 1
fi
EXTRA_TAGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--tag)
EXTRA_TAGS+=("$2")
shift 2
;;
*)
echo "Unknown argument: $1" >&2
echo "Usage: restic-backup [--tag TAG]" >&2
exit 1
;;
esac
done
echo "--------------------------------"
echo "Restic Backup"
echo "--------------------------------"
DIRECTORY="${DIRECTORY/#\~/$HOME}"
PATHS=$(echo "$BACKUP_PATHS" | python3 -c "import json, sys; print(' '.join(json.load(sys.stdin)))" 2> /dev/null || echo ".")
EXCLUDES=$(echo "$EXCLUDE_PATTERNS" | python3 -c "import json, sys; [print(f'--exclude={p}') for p in json.load(sys.stdin)]" 2> /dev/null || echo "")
TAGS=$(echo "$BACKUP_TAGS" | python3 -c "import json, sys; [print(f'--tag={t}') for t in json.load(sys.stdin)]" 2> /dev/null || echo "")
TAG_ARGS=(
"--tag=workspace-id:$RESTIC_WORKSPACE_ID"
"--tag=workspace-owner:$RESTIC_WORKSPACE_OWNER"
"--tag=workspace-name:$RESTIC_WORKSPACE_NAME"
)
if [ -n "$TAGS" ]; then
while IFS= read -r tag; do
[ -n "$tag" ] && TAG_ARGS+=("$tag")
done <<< "$TAGS"
fi
for tag in "${EXTRA_TAGS[@]}"; do
TAG_ARGS+=("--tag=$tag")
done
EXCLUDE_ARGS=()
if [ -n "$EXCLUDES" ]; then
while IFS= read -r exclude; do
[ -n "$exclude" ] && EXCLUDE_ARGS+=("$exclude")
done <<< "$EXCLUDES"
fi
cd "$DIRECTORY" || {
echo "Error: Failed to change to directory: $DIRECTORY" >&2
exit 1
}
echo "Working directory: $(pwd)"
echo "Backup paths: $PATHS"
echo "Tags: ${TAG_ARGS[*]}"
[ ${#EXCLUDE_ARGS[@]} -gt 0 ] && echo "Exclusions: ${EXCLUDE_ARGS[*]}"
echo "Starting backup..."
# shellcheck disable=SC2086
if restic backup $PATHS "${TAG_ARGS[@]}" "${EXCLUDE_ARGS[@]}"; then
echo "Backup completed successfully"
else
echo "Error: Backup failed" >&2
exit 1
fi
if [ "$AUTO_FORGET" = "true" ]; then
echo "Applying retention policies..."
FORGET_ARGS=(
"--tag=workspace-id:$RESTIC_WORKSPACE_ID"
"--keep-last=$RETENTION_LAST"
)
[ "$RETENTION_DAILY" -gt 0 ] && FORGET_ARGS+=("--keep-daily=$RETENTION_DAILY")
[ "$RETENTION_WEEKLY" -gt 0 ] && FORGET_ARGS+=("--keep-weekly=$RETENTION_WEEKLY")
[ "$RETENTION_MONTHLY" -gt 0 ] && FORGET_ARGS+=("--keep-monthly=$RETENTION_MONTHLY")
if [ "$AUTO_PRUNE" = "true" ]; then
FORGET_ARGS+=("--prune")
echo "Pruning unreferenced data..."
fi
if restic forget "${FORGET_ARGS[@]}"; then
echo "Retention policies applied"
else
echo "Warning: Failed to apply retention policies" >&2
fi
fi
echo "Backup process complete"
@@ -0,0 +1,296 @@
#!/usr/bin/env bash
set -euo pipefail
: $${CODER_SCRIPT_BIN_DIR:=$HOME/.local/bin}
: $${CODER_SCRIPT_DATA_DIR:=$HOME/.local/share/coder}
mkdir -p "$CODER_SCRIPT_BIN_DIR"
mkdir -p "$CODER_SCRIPT_DATA_DIR"
export PATH="$HOME/.local/bin:$PATH"
INSTALL_RESTIC="${INSTALL_RESTIC}"
RESTIC_VERSION="${RESTIC_VERSION}"
AUTO_INIT="${AUTO_INIT}"
RESTORE_ON_START="${RESTORE_ON_START}"
SNAPSHOT_ID="${SNAPSHOT_ID}"
RESTORE_TARGET="${RESTORE_TARGET}"
BACKUP_INTERVAL="${BACKUP_INTERVAL}"
BACKUP_PATHS='${BACKUP_PATHS}'
EXCLUDE_PATTERNS='${EXCLUDE_PATTERNS}'
BACKUP_TAGS='${BACKUP_TAGS}'
DIRECTORY="${DIRECTORY}"
RETENTION_LAST="${RETENTION_LAST}"
RETENTION_DAILY="${RETENTION_DAILY}"
RETENTION_WEEKLY="${RETENTION_WEEKLY}"
RETENTION_MONTHLY="${RETENTION_MONTHLY}"
AUTO_FORGET="${AUTO_FORGET}"
AUTO_PRUNE="${AUTO_PRUNE}"
BACKUP_SCRIPT_B64='${BACKUP_SCRIPT_B64}'
echo "--------------------------------"
echo "Restic Backup Module Setup"
echo "--------------------------------"
detect_os_arch() {
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH="amd64"
;;
aarch64 | arm64)
ARCH="arm64"
;;
armv7l)
ARCH="arm"
;;
*)
echo "Unsupported architecture: $ARCH"
exit 1
;;
esac
case "$OS" in
linux | darwin) ;;
*)
echo "Unsupported OS: $OS"
exit 1
;;
esac
echo "Detected OS: $OS, Architecture: $ARCH"
}
install_restic() {
if [ "$INSTALL_RESTIC" != "true" ]; then
echo "Skipping Restic installation (install_restic=false)"
return
fi
if command -v restic > /dev/null 2>&1; then
INSTALLED_VERSION=$(restic version | head -n1 | awk '{print $2}')
echo "Restic already installed: $INSTALLED_VERSION"
if [ "$RESTIC_VERSION" != "latest" ] && [ "$INSTALLED_VERSION" != "$RESTIC_VERSION" ]; then
echo "Warning: Version mismatch (installed: $INSTALLED_VERSION, requested: $RESTIC_VERSION)"
fi
return
fi
echo "Installing Restic..."
detect_os_arch
if [ "$RESTIC_VERSION" = "latest" ]; then
echo "Fetching latest version..."
LATEST_VERSION=$(curl -fsSL https://api.github.com/repos/restic/restic/releases/latest | grep '"tag_name":' | sed -E 's/.*"v([^"]+)".*/\1/')
if [ -z "$LATEST_VERSION" ]; then
echo "Error: Failed to fetch latest version"
exit 1
fi
echo "Version: $LATEST_VERSION"
DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v$${LATEST_VERSION}/restic_$${LATEST_VERSION}_$${OS}_$${ARCH}.bz2"
else
DOWNLOAD_URL="https://github.com/restic/restic/releases/download/v${RESTIC_VERSION}/restic_${RESTIC_VERSION}_$${OS}_$${ARCH}.bz2"
fi
echo "Downloading Restic..."
mkdir -p "$HOME/.local/bin"
TMP_FILE=$(mktemp)
if curl -fsSL "$DOWNLOAD_URL" -o "$TMP_FILE"; then
bunzip2 -c "$TMP_FILE" > "$HOME/.local/bin/restic"
chmod +x "$HOME/.local/bin/restic"
rm "$TMP_FILE"
echo "Restic installed: $($HOME/.local/bin/restic version)"
else
echo "Error: Download failed"
rm -f "$TMP_FILE"
exit 1
fi
}
verify_installation() {
if ! command -v restic > /dev/null 2>&1; then
echo "Error: restic command not found in PATH"
echo "PATH: $PATH"
if [ "$INSTALL_RESTIC" = "true" ]; then
exit 1
else
echo "Warning: restic not found but install_restic=false, continuing anyway"
return
fi
fi
echo "Restic verified: $(restic version | head -n1)"
}
init_repository() {
if [ "$AUTO_INIT" != "true" ]; then
echo "Skipping repository initialization (auto_init_repo=false)"
return
fi
echo "Checking repository..."
if restic snapshots > /dev/null 2>&1; then
echo "Repository already initialized"
return
fi
echo "Initializing repository..."
if restic init; then
echo "Repository initialized"
else
echo "Error: Failed to initialize repository"
exit 1
fi
}
install_backup_helper() {
echo "Installing backup helper script..."
HELPER_SCRIPT="$CODER_SCRIPT_BIN_DIR/restic-backup"
echo -n "$BACKUP_SCRIPT_B64" | base64 -d > "$HELPER_SCRIPT"
chmod +x "$HELPER_SCRIPT"
cat > "$CODER_SCRIPT_DATA_DIR/restic-backup.conf" << EOF
BACKUP_PATHS='$BACKUP_PATHS'
EXCLUDE_PATTERNS='$EXCLUDE_PATTERNS'
BACKUP_TAGS='$BACKUP_TAGS'
DIRECTORY='$DIRECTORY'
RETENTION_LAST='$RETENTION_LAST'
RETENTION_DAILY='$RETENTION_DAILY'
RETENTION_WEEKLY='$RETENTION_WEEKLY'
RETENTION_MONTHLY='$RETENTION_MONTHLY'
AUTO_FORGET='$AUTO_FORGET'
AUTO_PRUNE='$AUTO_PRUNE'
EOF
if [ ! -x "$HELPER_SCRIPT" ]; then
echo "Error: Backup helper is not executable"
exit 1
fi
echo "Backup helper installed: $HELPER_SCRIPT"
echo "Backup helper verified as executable"
}
find_latest_snapshot() {
local TAG_FILTER="$1"
SNAPSHOTS_JSON=$(restic snapshots --tag "$TAG_FILTER" --json 2> /dev/null || echo "[]")
LATEST_SNAPSHOT=$(echo "$SNAPSHOTS_JSON" | python3 -c "
import json, sys
snapshots = json.load(sys.stdin)
if snapshots:
latest = max(snapshots, key=lambda s: s['time'])
print(latest['short_id'])
else:
print('')
" 2> /dev/null || echo "")
echo "$LATEST_SNAPSHOT"
}
restore_on_start() {
if [ "$RESTORE_ON_START" != "true" ]; then
echo "Skipping restore (restore_on_start=false)"
return
fi
echo "--------------------------------"
echo "Restore Configuration"
echo "--------------------------------"
SNAPSHOT_TO_RESTORE=""
if [ -n "$SNAPSHOT_ID" ]; then
echo "Restoring specific snapshot: $SNAPSHOT_ID"
SNAPSHOT_TO_RESTORE="$SNAPSHOT_ID"
else
echo "Finding latest backup for this workspace..."
SNAPSHOT_TO_RESTORE=$(find_latest_snapshot "workspace-id:$RESTIC_WORKSPACE_ID")
if [ -z "$SNAPSHOT_TO_RESTORE" ]; then
echo "No previous backup found"
echo "Starting with fresh workspace"
return
fi
echo "Found snapshot: $SNAPSHOT_TO_RESTORE"
fi
echo "Restoring to $RESTORE_TARGET..."
if restic restore "$SNAPSHOT_TO_RESTORE" --target "$RESTORE_TARGET"; then
echo "Restore completed successfully"
else
echo "Error: Restore failed"
exit 1
fi
}
setup_interval_backup() {
if [ "$BACKUP_INTERVAL" -eq 0 ]; then
return
fi
echo "Setting up interval backup (every $BACKUP_INTERVAL minutes)..."
cat > "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" << 'EOFSCRIPT'
#!/usr/bin/env bash
set -euo pipefail
INTERVAL_MINUTES="$1"
INTERVAL_SECONDS=$((INTERVAL_MINUTES * 60))
echo "Starting interval backup loop (every $INTERVAL_MINUTES minutes)"
while true; do
sleep "$INTERVAL_SECONDS"
echo "Running scheduled backup..."
if "$CODER_SCRIPT_BIN_DIR/restic-backup" --tag "interval-backup"; then
echo "Scheduled backup completed"
else
echo "Scheduled backup failed"
fi
done
EOFSCRIPT
chmod +x "$CODER_SCRIPT_DATA_DIR/interval-backup.sh"
nohup "$CODER_SCRIPT_DATA_DIR/interval-backup.sh" "$BACKUP_INTERVAL" \
>> "$CODER_SCRIPT_DATA_DIR/interval-backup.log" 2>&1 &
echo "Interval backup started in background (PID: $!)"
}
main() {
install_restic
verify_installation
init_repository
install_backup_helper
restore_on_start
setup_interval_backup
echo "--------------------------------"
echo "Restic setup complete"
echo "--------------------------------"
echo "Available commands:"
echo " restic-backup - Run manual backup"
echo " restic snapshots - List all snapshots"
echo " restic restore <id> - Restore specific snapshot"
echo ""
echo "Repository: $${RESTIC_REPOSITORY:-not set}"
}
main
+5 -5
View File
@@ -19,7 +19,7 @@ Zed is a high-performance, multiplayer code editor from the creators of Atom and
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = coder_agent.example.id
}
```
@@ -32,7 +32,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = coder_agent.example.id
folder = "/home/coder/project"
}
@@ -44,7 +44,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = coder_agent.example.id
display_name = "Zed Editor"
order = 1
@@ -57,7 +57,7 @@ module "zed" {
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = coder_agent.example.id
agent_name = coder_agent.example.name
}
@@ -73,7 +73,7 @@ You can declaratively set/merge settings with the `settings` input. Provide a JS
module "zed" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/zed/coder"
version = "1.1.1"
version = "1.1.0"
agent_id = coder_agent.example.id
settings = jsonencode({
-1
View File
@@ -73,7 +73,6 @@ resource "coder_script" "zed_settings" {
icon = "/icon/zed.svg"
run_on_start = true
script = <<-EOT
#!/bin/sh
set -eu
SETTINGS_JSON='${replace(var.settings, "\"", "\\\"")}'
if [ -z "$${SETTINGS_JSON}" ] || [ "$${SETTINGS_JSON}" = "{}" ]; then
@@ -264,7 +264,7 @@ resource "kubernetes_deployment" "main" {
container {
name = "dev"
image = var.cache_repo == "" ? local.devcontainer_builder_image : envbuilder_cached_image.cached.0.image
image_pull_policy = "IfNotPresent"
image_pull_policy = "Always"
security_context {
privileged = true
}
@@ -455,4 +455,4 @@ resource "coder_metadata" "container_info" {
key = "cache repo"
value = var.cache_repo == "" ? "not enabled" : var.cache_repo
}
}
}
@@ -152,7 +152,7 @@ resource "kubernetes_pod" "main" {
name = "dev"
# We highly recommend pinning this to a specific release of envbox, as the latest tag may change.
image = "ghcr.io/coder/envbox:latest"
image_pull_policy = "IfNotPresent"
image_pull_policy = "Always"
command = ["/envbox", "docker"]
security_context {
@@ -310,4 +310,4 @@ resource "kubernetes_pod" "main" {
}
}
}
}
}
+1 -1
View File
@@ -287,7 +287,7 @@ resource "kubernetes_deployment" "main" {
container {
name = "dev"
image = "codercom/enterprise-base:ubuntu"
image_pull_policy = "IfNotPresent"
image_pull_policy = "Always"
command = ["sh", "-c", coder_agent.main.init_script]
security_context {
run_as_user = "1000"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

-11
View File
@@ -1,11 +0,0 @@
---
display_name: "Austin"
bio: "IT Pro by day, script kiddie at night."
avatar: "./.images/avatar.png"
github: "djarbz"
status: "community"
---
# Austin
I like to program as a hobby.
@@ -1,68 +0,0 @@
---
display_name: copyparty
description: A web based file explorer alternative to Filebrowser.
icon: ../../../../.icons/copyparty.svg
verified: false
tags: [files, filebrowser, web, copyparty]
---
# copyparty
<!-- Describes what this module does -->
This module installs Copyparty, an alternative to Filebrowser.
[Copyparty](https://github.com/9001/copyparty) is a portable file server with accelerated resumable uploads, dedup, WebDAV, FTP, TFTP, zeroconf, media indexer, thumbnails++ all in one file, no deps
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
}
```
<!-- Add a screencast or screenshot here put them in .images directory -->
![copyparty-browser-fs8](../../.images/copyparty_screenshot.png)
## Examples
### Example 1
Some basic command line options:
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
arguments = [
"-v", "/home/coder/:/home:r", # Share home directory (read-only)
"-v", "${local.repo_dir}:/repo:rw", # Share project directory (read-write)
"-e2dsa", # Enables general file indexing"
]
}
```
### Example 2
```tf
module "copyparty" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/djarbz/copyparty/coder"
version = "1.0.0"
agent_id = coder_agent.example.id
subdomain = true
arguments = [
"-v", "/tmp:/tmp:r", # Share tmp directory (read-only)
"-v", "/home/coder/:/home:rw", # Share home directory (read-write)
"-v", "${local.root_dir}:/work:A:c,dotsrch", # Share work directory (All Perms)
"-e2dsa", # Enables general file indexing"
"--re-maxage", "900", # Rescan filesystem for changes every SEC
"--see-dots", # Show dotfiles by default if user has correct permissions on volume
"--xff-src=lan", # List of trusted reverse-proxy CIDRs (comma-separated) or `lan` for private IPs.
"--rproxy", "1", # Which ip to associate clients with, index of X-FWD IP.
]
}
```
@@ -1,181 +0,0 @@
# --- Test Case 1: Required Variables ---
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "example-agent-id"
}
}
# --- Test Case 2: Coder App URL uses custom port ---
run "app_url_uses_port" {
command = plan
variables {
agent_id = "example-agent-id"
port = 19999
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:19999"
error_message = "Expected copyparty app URL to include configured port"
}
}
# --- Test Case 3: Default Values ---
run "test_defaults" {
# This run block applies the module with default values
# (except for the required 'agent_id' provided above).
variables {
agent_id = "example-agent-id"
}
# --- Asserts for coder_app "copyparty" ---
assert {
condition = resource.coder_app.copyparty.display_name == "copyparty"
error_message = "Default display_name is incorrect"
}
assert {
condition = resource.coder_app.copyparty.slug == "copyparty"
error_message = "Default slug is incorrect"
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:3923"
error_message = "Default URL is incorrect, expected port 3923"
}
assert {
condition = resource.coder_app.copyparty.subdomain == false
error_message = "Default subdomain should be false"
}
assert {
condition = resource.coder_app.copyparty.share == "owner"
error_message = "Default share value should be 'owner'"
}
assert {
condition = resource.coder_app.copyparty.open_in == "slim-window"
error_message = "Default open_in value should be 'slim-window'"
}
# --- Asserts for coder_script "copyparty" ---
assert {
condition = coder_script.copyparty.display_name == "copyparty"
error_message = "Script display_name is incorrect"
}
# Check rendered script content (this assumes your run.sh uses the variables)
assert {
condition = strcontains(coder_script.copyparty.script, "PORT=\"3923\"")
error_message = "Script content does not reflect default port"
}
assert {
condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/tmp/copyparty.log\"")
error_message = "Script content does not reflect default log_path"
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"\"")
error_message = "Script content does not reflect default empty arguments"
}
}
# --- Test Case 4: Custom Values ---
run "test_custom_values" {
# Override default variables for this specific run
variables {
agent_id = "example-agent-id"
port = 8080
slug = "my-custom-app"
display_name = "My Custom App"
share = "authenticated"
open_in = "tab"
pinned_version = "v1.2.3"
arguments = ["--verbose", "-v"]
log_path = "/var/log/custom.log"
}
# --- Asserts for coder_app "copyparty" ---
assert {
condition = resource.coder_app.copyparty.display_name == "My Custom App"
error_message = "Custom display_name was not applied"
}
assert {
condition = resource.coder_app.copyparty.slug == "my-custom-app"
error_message = "Custom slug was not applied"
}
assert {
condition = resource.coder_app.copyparty.url == "http://localhost:8080"
error_message = "Custom port was not applied to URL"
}
assert {
condition = resource.coder_app.copyparty.share == "authenticated"
error_message = "Custom share value was not applied"
}
assert {
condition = resource.coder_app.copyparty.open_in == "tab"
error_message = "Custom open_in value was not applied"
}
# --- Asserts for coder_script "copyparty" ---
assert {
condition = strcontains(coder_script.copyparty.script, "PORT=\"8080\"")
error_message = "Script content does not reflect custom port"
}
assert {
condition = strcontains(coder_script.copyparty.script, "PINNED_VERSION=\"v1.2.3\"")
error_message = "Script content does not reflect custom pinned_version"
}
assert {
condition = strcontains(coder_script.copyparty.script, "IFS=',' read -r -a ARGUMENTS \u003c\u003c\u003c \"--verbose,-v\"")
error_message = "Script content does not reflect custom arguments"
}
assert {
condition = strcontains(coder_script.copyparty.script, "LOG_PATH=\"/var/log/custom.log\"")
error_message = "Script content does not reflect custom log_path"
}
}
# --- Test Case 5: Validation Failure (open_in) ---
run "test_invalid_open_in" {
# This is a 'plan' test that expects a failure
command = plan
variables {
agent_id = "example-agent-id"
open_in = "invalid-value"
}
# Expect this plan to fail due to the validation rule in 'var.open_in'
expect_failures = [
var.open_in,
]
}
# --- Test Case 6: Validation Failure (share) ---
run "test_invalid_share" {
# This is a 'plan' test that expects a failure
command = plan
variables {
agent_id = "example-agent-id"
share = "everyone" # This is not 'owner', 'authenticated', or 'public'
}
# Expect this plan to fail due to the validation rule in 'var.share'
expect_failures = [
var.share,
]
}
-174
View File
@@ -1,174 +0,0 @@
terraform {
required_version = ">= 1.0"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
locals {
# A built-in icon like "/icon/code.svg" or a full URL of icon
icon_url = "/icon/copyparty.svg"
# a map of all possible values
# options = {
# "Option 1" = {
# "name" = "Option 1",
# "value" = "1"
# "icon" = "/emojis/1.png"
# }
# "Option 2" = {
# "name" = "Option 2",
# "value" = "2"
# "icon" = "/emojis/2.png"
# }
# }
}
# Add required variables for your modules and remove any unneeded variables
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "log_path" {
type = string
description = "The path to log copyparty to."
default = "/tmp/copyparty.log"
}
variable "port" {
type = number
description = "ports to listen on (comma/range); ignored for unix-sockets (default: 3923)"
default = 3923
}
variable "slug" {
type = string
description = "The slug for the copyparty application."
default = "copyparty"
}
variable "display_name" {
type = string
description = "The display name for the copyparty application."
default = "copyparty"
}
variable "group" {
type = string
description = "The name of a group that this app belongs to."
default = null
}
variable "open_in" {
type = string
description = <<-EOT
Determines where the app will be opened. Valid values are `"tab"` and `"slim-window" (default)`.
`"tab"` opens in a new tab in the same browser window.
`"slim-window"` opens a new browser window without navigation controls.
EOT
default = "slim-window"
validation {
condition = contains(["tab", "slim-window"], var.open_in)
error_message = "The 'open_in' variable must be one of: 'tab', 'slim-window'."
}
}
variable "subdomain" {
type = bool
description = <<-EOT
Determines whether the app will be accessed via it's own subdomain or whether it will be accessed via a path on Coder.
If wildcards have not been setup by the administrator then apps with "subdomain" set to true will not be accessible.
EOT
default = false
}
variable "share" {
type = string
default = "owner"
validation {
condition = var.share == "owner" || var.share == "authenticated" || var.share == "public"
error_message = "Incorrect value. Please set either 'owner', 'authenticated', or 'public'."
}
}
# variable "mutable" {
# type = bool
# description = "Whether the parameter is mutable."
# default = true
# }
variable "order" {
type = number
description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)."
default = null
}
# Add other variables here
variable "pinned_version" {
type = string
description = "Install a specific version in semver format (v1.19.16)."
default = ""
}
variable "arguments" {
type = list(string)
description = "A list of arguments to pass to the application."
default = []
}
resource "coder_script" "copyparty" {
agent_id = var.agent_id
display_name = "copyparty"
icon = local.icon_url
script = templatefile("${path.module}/run.sh", {
LOG_PATH : var.log_path,
PORT : var.port,
PINNED_VERSION : var.pinned_version,
ARGUMENTS : join(",", var.arguments),
})
run_on_start = true
run_on_stop = false
}
resource "coder_app" "copyparty" {
agent_id = var.agent_id
slug = var.slug
display_name = var.display_name
url = "http://localhost:${var.port}"
icon = local.icon_url
subdomain = var.subdomain
share = var.share
order = var.order
group = var.group
open_in = var.open_in
# Remove if the app does not have a healthcheck endpoint
healthcheck {
url = "http://localhost:${var.port}"
interval = 5
threshold = 6
}
}
# data "coder_parameter" "copyparty" {
# type = "list(string)"
# name = "copyparty"
# display_name = "copyparty"
# icon = local.icon_url
# mutable = var.mutable
# default = local.options["Option 1"]["value"]
# dynamic "option" {
# for_each = local.options
# content {
# icon = option.value.icon
# name = option.value.name
# value = option.value.value
# }
# }
# }
-100
View File
@@ -1,100 +0,0 @@
#!/usr/bin/env bash
# Convert templated variables to shell variables
# This variable is assigned to itself, so the assignment does nothing.
# shellcheck disable=SC2269
LOG_PATH="${LOG_PATH}"
# Ports to listen on (comma/range); ignored for unix-sockets (default: 3923)
PORT="${PORT}"
# Pinned version (e.g., v1.19.16); overrides latest release discovery if set
PINNED_VERSION="${PINNED_VERSION}"
# Custom CLI Arguments# The variable from Terraform is a single, comma-separated string.
# We need to split it into a proper bash array using the comma (,) as the delimiter.
IFS=',' read -r -a ARGUMENTS <<< "${ARGUMENTS}"
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
MODULE_NAME="Copyparty"
# VARIABLE appears unused. Verify use (or export if used externally).
# shellcheck disable=SC2034
BOLD='\033[0;1m'
printf '%sInstalling %s ...\n\n' "$${BOLD}" "$${MODULE_NAME}"
# Add code here
# Use variables from the templatefile function in main.tf
# e.g. LOG_PATH, PORT, etc.
printf "🐍 Verifying Python 3 installation...\n"
if ! command -v python3 &> /dev/null; then
printf "❌ Python3 could not be found. Please install it to continue.\n"
exit 1
fi
printf "✅ Python3 is installed.\n\n"
RELEASE_TO_INSTALL=""
# Install provided version to pin, otherwise discover latest github release from `https://github.com/9001/copyparty`.
if [[ -n "$${PINNED_VERSION}" ]]; then
printf "📌 Pinned version specified: %s\n" "$${PINNED_VERSION}"
# Verify that it is in v#.#.# format
if [[ ! "$${PINNED_VERSION}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
printf "❌ Invalid format for PINNED_VERSION. Expected 'v#.#.#' (e.g., v1.19.16).\n"
exit 1
fi
RELEASE_TO_INSTALL="$${PINNED_VERSION}"
printf "✅ Using pinned version %s.\n\n" "$${RELEASE_TO_INSTALL}"
else
printf "🔎 Discovering latest release from GitHub...\n"
# Use curl to get the latest release tag from the GitHub API and sed to parse it
LATEST_RELEASE=$(curl -fsSL https://api.github.com/repos/9001/copyparty/releases/latest | grep '"tag_name":' | sed -E 's/.*"(v[^"]+)".*/\1/')
if [[ -z "$${LATEST_RELEASE}" ]]; then
printf "❌ Could not determine the latest release. Please check your internet connection.\n"
exit 1
fi
RELEASE_TO_INSTALL="$${LATEST_RELEASE}"
printf "🏷️ Latest release is %s.\n\n" "$${RELEASE_TO_INSTALL}"
fi
# Download appropriate release version assets: `copyparty-sfx.py` and `helptext.html`.
printf "🚀 Downloading copyparty v%s...\n" "$${RELEASE_TO_INSTALL}"
DOWNLOAD_URL="https://github.com/9001/copyparty/releases/download/$${RELEASE_TO_INSTALL}"
printf "⏬ Downloading copyparty-sfx.py...\n"
if ! curl -fsSL -o /tmp/copyparty-sfx.py "$${DOWNLOAD_URL}/copyparty-sfx.py"; then
printf "❌ Failed to download copyparty-sfx.py.\n"
exit 1
fi
printf "⏬ Downloading helptext.html...\n"
if ! curl -fsSL -o /tmp/helptext.html "$${DOWNLOAD_URL}/helptext.html"; then
# This is not a fatal error, just a warning.
printf "⚠️ Could not download helptext.html. The application will still work.\n"
fi
chmod +x /tmp/copyparty-sfx.py
printf "✅ Download complete.\n\n"
printf "🥳 Installation complete!\n\n"
# Build a clean, quoted string of the command for logging purposes only.
log_command="python3 /tmp/copyparty-sfx.py -p '$${PORT}'"
for arg in "$${ARGUMENTS[@]}"; do
# printf "DEBUG: ARG [$${arg}]\n"
log_command+=" '$${arg}'"
done
# Clear the log file and write the header and command string using printf.
{
printf "=== Starting copyparty at %s ===\n" "$(date)"
printf "EXECUTING: %s\n" "$${log_command}"
} > "$${LOG_PATH}"
printf "👷 Starting %s in background...\n\n" "$${MODULE_NAME}"
# Execute the actual command using the robust array expansion.
# Then, append its output (stdout and stderr) to the log file.
python3 /tmp/copyparty-sfx.py -p "$${PORT}" "$${ARGUMENTS[@]}" >> "$${LOG_PATH}" 2>&1 &
printf "✅ Service started. Check logs at %s\n\n" "$${LOG_PATH}"
@@ -1,70 +0,0 @@
---
display_name: "NFS K8s Deployment"
description: "Mount an NFS share to a Coder K8s workspace"
icon: "../../../../.icons/folder.svg"
verified: false
tags: ["kubernetes", "shared-dir", "nfs"]
---
# NFS K8s Deployment
This template provisions a Coder workspace as a Kubernetes Deployment, with an NFS share mounted
as a volume. The NFS share will synchronize the server-side files onto the client (Coder workspace)
When you stop the Coder workspace and rebuild, the NFS share will be re-mounted, and the changes persisted.
Note the `volume` and `volume_mount` blocks in the deployment and container spec,
respectively:
```terraform
resource "kubernetes_deployment" "main" {
spec {
template {
spec {
container {
volume_mount {
mount_path = data.coder_parameter.nfs_mount_path.value # mount path in the container
name = "nfs-share"
}
}
volume {
name = "nfs-share"
nfs {
path = data.coder_parameter.nfs_mount_path.value # path to be exported from the server
server = data.coder_parameter.nfs_server.value # server IP address
}
}
}
}
}
}
```
## server-side configuration
1. Create an NFS mount on the server for the clients to access:
```console
export NFS_MNT_PATH=/mnt/nfs_share
# Create directory to shaare
sudo mkdir -p $NFS_MNT_PATH
# Assign UID & GIDs access
sudo chown -R uid:gid $NFS_MNT_PATH
sudo chmod 777 $NFS_MNT_PATH
```
1. Grant access to the client by updating the `/etc/exports` file, which
controls the directories shared with remote clients. See
[Red Hat's docs for more information about the configuration options](https://access.redhat.com/documentation/en-us/red_hat_enterprise_linux/5/html/deployment_guide/s1-nfs-server-config-exports).
```console
# Provides read/write access to clients accessing the NFS from any IP address.
/mnt/nfs_share *(rw,sync,no_subtree_check)
```
1. Export the NFS file share directory. You must do this every time you change
`/etc/exports`.
```console
sudo exportfs -a
sudo systemctl restart <nfs-package>
```
@@ -1,348 +0,0 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
kubernetes = {
source = "hashicorp/kubernetes"
}
}
}
provider "coder" {
}
provider "kubernetes" {
config_path = var.use_kubeconfig == true ? "~/.kube/config" : null
}
variable "use_kubeconfig" {
type = bool
description = <<-EOF
Use host kubeconfig? (true/false)
Set this to false if the Coder host is itself running as a Pod on the same
Kubernetes cluster as you are deploying workspaces to.
Set this to true if the Coder host is running outside the Kubernetes cluster
for workspaces. A valid "~/.kube/config" must be present on the Coder host.
EOF
default = false
}
variable "namespace" {
type = string
description = "The Kubernetes namespace to create workspaces in (must exist prior to creating workspaces). If the Coder host is itself running as a Pod on the same Kubernetes cluster as you are deploying workspaces to, set this to the same namespace."
}
data "coder_workspace" "me" {}
data "coder_workspace_owner" "me" {}
data "coder_parameter" "cpu" {
name = "cpu"
display_name = "CPU"
description = "The number of CPU cores"
default = "2"
icon = "/icon/memory.svg"
mutable = true
option {
name = "2 Cores"
value = "2"
}
option {
name = "4 Cores"
value = "4"
}
option {
name = "6 Cores"
value = "6"
}
option {
name = "8 Cores"
value = "8"
}
}
data "coder_parameter" "memory" {
name = "memory"
display_name = "Memory"
description = "The amount of memory in GB"
default = "2"
icon = "/icon/memory.svg"
mutable = true
option {
name = "2 GB"
value = "2"
}
option {
name = "4 GB"
value = "4"
}
option {
name = "6 GB"
value = "6"
}
option {
name = "8 GB"
value = "8"
}
}
data "coder_parameter" "home_disk_size" {
name = "home_disk_size"
display_name = "Home disk size"
description = "The size of the home disk in GB"
default = "10"
type = "number"
icon = "/emojis/1f4be.png"
mutable = false
validation {
min = 1
max = 99999
}
}
data "coder_parameter" "nfs_server" {
name = "nfs_server"
type = "string"
display_name = "NFS Server IP"
description = "The NFS server IP address to use for the workspace"
}
data "coder_parameter" "nfs_mount_path" {
name = "nfs_mount_path"
type = "string"
display_name = "NFS Mount Path"
description = "The path in your workspace container to mount the NFS share to"
default = "/mnt/nfs-share"
validation {
regex = "^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*$"
error = "NFS mount path must be a valid path in your workspace container"
}
}
resource "coder_agent" "coder" {
os = "linux"
arch = "amd64"
# The following metadata blocks are optional. They are used to display
# information about your workspace in the dashboard. You can remove them
# if you don't want to display any information.
# For basic resources, you can use the `coder stat` command.
# If you need more control, you can write your own script.
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
metadata {
display_name = "Home Disk"
key = "3_home_disk"
script = "coder stat disk --path $${HOME}"
interval = 60
timeout = 1
}
metadata {
display_name = "CPU Usage (Host)"
key = "4_cpu_usage_host"
script = "coder stat cpu --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Memory Usage (Host)"
key = "5_mem_usage_host"
script = "coder stat mem --host"
interval = 10
timeout = 1
}
metadata {
display_name = "Load Average (Host)"
key = "6_load_host"
# get load avg scaled by number of cores
script = <<EOT
echo "`cat /proc/loadavg | awk '{ print $1 }'` `nproc`" | awk '{ printf "%0.2f", $1/$2 }'
EOT
interval = 60
timeout = 1
}
}
module "vscode-web" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/vscode-web/coder"
version = "1.3.1"
agent_id = coder_agent.coder.id
accept_license = true
}
resource "kubernetes_deployment" "main" {
count = data.coder_workspace.me.start_count
depends_on = [
kubernetes_persistent_volume_claim.home
]
wait_for_rollout = false
metadata {
name = "coder-${data.coder_workspace.me.id}"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
}
}
spec {
replicas = 1
selector {
match_labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
}
strategy {
type = "Recreate"
}
template {
metadata {
labels = {
"app.kubernetes.io/name" = "coder-workspace"
"app.kubernetes.io/instance" = "coder-workspace-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
}
spec {
container {
name = "dev"
image = "codercom/enterprise-base:ubuntu"
image_pull_policy = "Always"
command = ["sh", "-c", coder_agent.coder.init_script]
env {
name = "CODER_AGENT_TOKEN"
value = coder_agent.coder.token
}
resources {
requests = {
"cpu" = "250m"
"memory" = "512Mi"
}
limits = {
"cpu" = "${data.coder_parameter.cpu.value}"
"memory" = "${data.coder_parameter.memory.value}Gi"
}
}
volume_mount {
mount_path = "/home/${lower(data.coder_workspace_owner.me.name)}"
name = "home"
read_only = false
}
volume_mount {
mount_path = data.coder_parameter.nfs_mount_path.value
name = "nfs-share"
}
}
volume {
name = "home"
persistent_volume_claim {
claim_name = kubernetes_persistent_volume_claim.home.metadata.0.name
read_only = false
}
}
volume {
name = "nfs-share"
nfs {
path = data.coder_parameter.nfs_mount_path.value
server = data.coder_parameter.nfs_server.value
}
}
affinity {
// This affinity attempts to spread out all workspace pods evenly across
// nodes.
pod_anti_affinity {
preferred_during_scheduling_ignored_during_execution {
weight = 1
pod_affinity_term {
topology_key = "kubernetes.io/hostname"
label_selector {
match_expressions {
key = "app.kubernetes.io/name"
operator = "In"
values = ["coder-workspace"]
}
}
}
}
}
}
}
}
}
}
resource "kubernetes_persistent_volume_claim" "home" {
metadata {
name = "${lower(data.coder_workspace_owner.me.name)}-${lower(data.coder_workspace_owner.me.name)}-home"
namespace = var.namespace
labels = {
"app.kubernetes.io/name" = "coder-pvc"
"app.kubernetes.io/instance" = "coder-pvc-${data.coder_workspace.me.id}"
"app.kubernetes.io/part-of" = "coder"
//Coder-specific labels.
"com.coder.resource" = "true"
"com.coder.workspace.id" = data.coder_workspace.me.id
"com.coder.workspace.name" = data.coder_workspace.me.name
"com.coder.user.id" = data.coder_workspace_owner.me.id
"com.coder.user.username" = data.coder_workspace_owner.me.name
}
annotations = {
"com.coder.user.email" = data.coder_workspace_owner.me.email
}
}
wait_until_bound = false
spec {
access_modes = ["ReadWriteOnce"]
resources {
requests = {
storage = "${data.coder_parameter.home_disk_size.value}Gi"
}
}
}
}