@intentius/plywood 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/dist/plywood.mjs +7806 -0
- package/dist/plywood.mjs.map +7 -0
- package/package.json +41 -4
- package/plywood.schema.json +1133 -0
- package/presets/dashboards/plywood.json +83 -0
- package/presets/policy/plywood-providers/PulumiPolicy.yaml +2 -0
- package/presets/policy/plywood-providers/index.ts +29 -0
- package/presets/policy/plywood-providers/package.json +10 -0
- package/presets/policy/plywood_providers.rego.tmpl +31 -0
- package/templates/agent-guard/bin/plunge +27 -0
- package/templates/agent-guard/bin/pulumi +27 -0
- package/templates/agent-guard/plywood-guard.mjs +60 -0
- package/templates/agent-prompt.md +6 -0
- package/templates/chant.config.ts.tmpl +10 -0
- package/templates/plywood.yml +8 -0
- package/README.md +0 -3
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"title": "plywood",
|
|
3
|
+
"description": "plywood's runs from chant's OTLP traces (chant#4555), read through a Tempo data source. Span attributes carry the plywood. prefix.",
|
|
4
|
+
"uid": "plywood-runs",
|
|
5
|
+
"schemaVersion": 39,
|
|
6
|
+
"version": 1,
|
|
7
|
+
"editable": true,
|
|
8
|
+
"time": { "from": "now-7d", "to": "now" },
|
|
9
|
+
"tags": ["plywood", "pulumi", "chant"],
|
|
10
|
+
"templating": {
|
|
11
|
+
"list": [
|
|
12
|
+
{ "name": "tempo", "label": "Traces", "type": "datasource", "query": "tempo" },
|
|
13
|
+
{
|
|
14
|
+
"name": "stack",
|
|
15
|
+
"label": "Stack",
|
|
16
|
+
"type": "custom",
|
|
17
|
+
"query": ".*",
|
|
18
|
+
"current": { "text": "all", "value": ".*" },
|
|
19
|
+
"description": "A regular expression over plywood.stack, such as shop/prod-.*"
|
|
20
|
+
}
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"panels": [
|
|
24
|
+
{
|
|
25
|
+
"id": 1,
|
|
26
|
+
"type": "table",
|
|
27
|
+
"title": "Runs",
|
|
28
|
+
"gridPos": { "x": 0, "y": 0, "w": 24, "h": 8 },
|
|
29
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
30
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ resource.service.name = \"plywood\" && span.plywood.run.id != nil }", "limit": 50 }]
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"id": 2,
|
|
34
|
+
"type": "table",
|
|
35
|
+
"title": "Updates per stack",
|
|
36
|
+
"gridPos": { "x": 0, "y": 8, "w": 12, "h": 8 },
|
|
37
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
38
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ span.plywood.stack =~ \"${stack}\" && span.plywood.up.result != nil } | select(span.plywood.stack, span.plywood.engine, span.plywood.up.result, span.plywood.up.duration_s)", "limit": 100 }]
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": 3,
|
|
42
|
+
"type": "table",
|
|
43
|
+
"title": "Failed updates",
|
|
44
|
+
"gridPos": { "x": 12, "y": 8, "w": 12, "h": 8 },
|
|
45
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
46
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ span.plywood.stack =~ \"${stack}\" && (span.plywood.up.result = \"failed\" || span.plywood.failure.code != nil) } | select(span.plywood.stack, span.plywood.failure.code, span.plywood.failure.exit_code)", "limit": 100 }]
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
"id": 4,
|
|
50
|
+
"type": "timeseries",
|
|
51
|
+
"title": "Update duration (s)",
|
|
52
|
+
"gridPos": { "x": 0, "y": 16, "w": 12, "h": 8 },
|
|
53
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
54
|
+
"targets": [{ "refId": "A", "queryType": "traceqlmetrics", "query": "{ span.plywood.stack =~ \"${stack}\" && span.plywood.up.duration_s != nil } | quantile_over_time(span.plywood.up.duration_s, .5, .95) by (span.plywood.stack)" }]
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"id": 5,
|
|
58
|
+
"type": "table",
|
|
59
|
+
"title": "Waits for an approval",
|
|
60
|
+
"gridPos": { "x": 12, "y": 16, "w": 12, "h": 8 },
|
|
61
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
62
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ event:name = \"gate-waiting\" } | select(event.plywood.gate.op, event.gate)", "limit": 50 }]
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"id": 6,
|
|
66
|
+
"type": "table",
|
|
67
|
+
"title": "plunge updates per stack",
|
|
68
|
+
"description": "Stacks on plunge: the resources an update wrote, its provider and record time, and its slowest call.",
|
|
69
|
+
"gridPos": { "x": 0, "y": 24, "w": 12, "h": 8 },
|
|
70
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
71
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ span.plywood.stack =~ \"${stack}\" && span.plywood.plunge.resources != nil } | select(span.plywood.stack, span.plywood.plunge.resources, span.plywood.plunge.calls, span.plywood.plunge.provider_ms, span.plywood.plunge.record_ms, span.plywood.plunge.failed_calls, span.plywood.plunge.slowest.urn, span.plywood.plunge.slowest.ms)", "limit": 100 }]
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"id": 7,
|
|
75
|
+
"type": "table",
|
|
76
|
+
"title": "plunge updates per resource",
|
|
77
|
+
"description": "One row per resource a plunge update wrote, with its provider and record time.",
|
|
78
|
+
"gridPos": { "x": 12, "y": 24, "w": 12, "h": 8 },
|
|
79
|
+
"datasource": { "type": "tempo", "uid": "${tempo}" },
|
|
80
|
+
"targets": [{ "refId": "A", "queryType": "traceql", "query": "{ span.plywood.plunge.calls != nil && span.plywood.plunge.resources = nil } | select(span.plywood.resource.type, span.plywood.resource.name, span.plywood.plunge.calls, span.plywood.plunge.provider_ms, span.plywood.plunge.record_ms)", "limit": 200 }]
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// plywood's provider allowlist as a CrossGuard pack. 'plywood init' writes it
|
|
2
|
+
// with plywood-providers.json beside it, the list from plywood.yml's
|
|
3
|
+
// policy.providers; list the pack under policy.packs with that config to run
|
|
4
|
+
// it in every preview and update. A resource whose provider package is not
|
|
5
|
+
// listed is a mandatory violation, which denies at chant's gate.
|
|
6
|
+
import { PolicyPack } from "@pulumi/policy";
|
|
7
|
+
|
|
8
|
+
/** The provider package of a Pulumi type, or undefined for the engine's own types. */
|
|
9
|
+
function packageOf(type: string): string | undefined {
|
|
10
|
+
if (type.startsWith("pulumi:providers:")) return type.slice("pulumi:providers:".length);
|
|
11
|
+
if (type.startsWith("pulumi:") || type === "unknown") return undefined;
|
|
12
|
+
return type.split(":")[0];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
new PolicyPack("plywood-providers", {
|
|
16
|
+
policies: [
|
|
17
|
+
{
|
|
18
|
+
name: "provider-allowlist",
|
|
19
|
+
description: "Every resource comes from a provider package plywood.yml's policy.providers lists.",
|
|
20
|
+
enforcementLevel: "mandatory",
|
|
21
|
+
configSchema: { properties: { allowed: { type: "array", items: { type: "string" } } } },
|
|
22
|
+
validateResource: (args, reportViolation) => {
|
|
23
|
+
const allowed = args.getConfig<{ allowed?: string[] }>().allowed ?? [];
|
|
24
|
+
const pkg = packageOf(args.type);
|
|
25
|
+
if (pkg !== undefined && !allowed.includes(pkg)) reportViolation(`${args.urn} uses the provider package ${pkg}, which policy.providers does not list`);
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
],
|
|
29
|
+
});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# Written by 'plywood init' from plywood.yml's policy.providers: edit the
|
|
2
|
+
# list there and run 'plywood init' again. chant's gate reads the change set
|
|
3
|
+
# as input.changeSet; a resource the change creates, updates or replaces
|
|
4
|
+
# whose provider package is not listed denies the preview.
|
|
5
|
+
package {{namespace}}
|
|
6
|
+
|
|
7
|
+
import rego.v1
|
|
8
|
+
|
|
9
|
+
allowed := {{allowed}}
|
|
10
|
+
|
|
11
|
+
# The provider package of a Pulumi type: aws for aws:s3/bucket:Bucket, and
|
|
12
|
+
# for a provider resource (pulumi:providers:aws) the package it provides.
|
|
13
|
+
package_of(t) := p if {
|
|
14
|
+
startswith(t, "pulumi:providers:")
|
|
15
|
+
p := substring(t, count("pulumi:providers:"), -1)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
package_of(t) := p if {
|
|
19
|
+
not startswith(t, "pulumi:")
|
|
20
|
+
t != "unknown"
|
|
21
|
+
p := split(t, ":")[0]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
deny_provider_package contains msg if {
|
|
25
|
+
some e in input.changeSet.entries
|
|
26
|
+
e.lexicon == "pulumi"
|
|
27
|
+
e.action in {"create", "update", "replace"}
|
|
28
|
+
p := package_of(e.type)
|
|
29
|
+
not p in allowed
|
|
30
|
+
msg := sprintf("%s uses the provider package %s, which policy.providers does not list", [e.address, p])
|
|
31
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# plywood's agent guard wrapper for plunge (plywood#19), written by 'plywood guard install'.
|
|
3
|
+
# First on PATH in an agent session: with PLYWOOD_AGENT_GUARD=1 or CLAUDECODE=1
|
|
4
|
+
# it refuses 'plunge up', 'plunge update' and 'plunge destroy', and runs every other
|
|
5
|
+
# command with the real plunge further down PATH.
|
|
6
|
+
self_dir=$(cd "$(dirname "$0")" && pwd)
|
|
7
|
+
if [ "${PLYWOOD_AGENT_GUARD:-}" = "1" ] || [ "${CLAUDECODE:-}" = "1" ]; then
|
|
8
|
+
skip=0
|
|
9
|
+
for arg in "$@"; do
|
|
10
|
+
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
|
11
|
+
case "$arg" in
|
|
12
|
+
-C|--cwd|-s|--stack|--color|--config-file|--logflow|-v|--verbose|--tracing|--profiling|--memprofilerate) skip=1 ;;
|
|
13
|
+
-*) ;;
|
|
14
|
+
up|update|destroy)
|
|
15
|
+
echo "plywood guard: an agent session does not run 'plunge $arg'. Open a pull request; plywood previews it, and the update runs after a person approves the digest." >&2
|
|
16
|
+
exit 1 ;;
|
|
17
|
+
*) break ;;
|
|
18
|
+
esac
|
|
19
|
+
done
|
|
20
|
+
fi
|
|
21
|
+
IFS=:
|
|
22
|
+
for dir in $PATH; do
|
|
23
|
+
[ "$(cd "$dir" 2>/dev/null && pwd)" = "$self_dir" ] && continue
|
|
24
|
+
if [ -x "$dir/plunge" ]; then exec "$dir/plunge" "$@"; fi
|
|
25
|
+
done
|
|
26
|
+
echo "plywood guard: no plunge on PATH after $self_dir" >&2
|
|
27
|
+
exit 127
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# plywood's agent guard wrapper for pulumi (plywood#19), written by 'plywood guard install'.
|
|
3
|
+
# First on PATH in an agent session: with PLYWOOD_AGENT_GUARD=1 or CLAUDECODE=1
|
|
4
|
+
# it refuses 'pulumi up', 'pulumi update' and 'pulumi destroy', and runs every other
|
|
5
|
+
# command with the real pulumi further down PATH.
|
|
6
|
+
self_dir=$(cd "$(dirname "$0")" && pwd)
|
|
7
|
+
if [ "${PLYWOOD_AGENT_GUARD:-}" = "1" ] || [ "${CLAUDECODE:-}" = "1" ]; then
|
|
8
|
+
skip=0
|
|
9
|
+
for arg in "$@"; do
|
|
10
|
+
if [ "$skip" = 1 ]; then skip=0; continue; fi
|
|
11
|
+
case "$arg" in
|
|
12
|
+
-C|--cwd|-s|--stack|--color|--config-file|--logflow|-v|--verbose|--tracing|--profiling|--memprofilerate) skip=1 ;;
|
|
13
|
+
-*) ;;
|
|
14
|
+
up|update|destroy)
|
|
15
|
+
echo "plywood guard: an agent session does not run 'pulumi $arg'. Open a pull request; plywood previews it, and the update runs after a person approves the digest." >&2
|
|
16
|
+
exit 1 ;;
|
|
17
|
+
*) break ;;
|
|
18
|
+
esac
|
|
19
|
+
done
|
|
20
|
+
fi
|
|
21
|
+
IFS=:
|
|
22
|
+
for dir in $PATH; do
|
|
23
|
+
[ "$(cd "$dir" 2>/dev/null && pwd)" = "$self_dir" ] && continue
|
|
24
|
+
if [ -x "$dir/pulumi" ]; then exec "$dir/pulumi" "$@"; fi
|
|
25
|
+
done
|
|
26
|
+
echo "plywood guard: no pulumi on PATH after $self_dir" >&2
|
|
27
|
+
exit 127
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// plywood's agent guard, a Claude Code PreToolUse hook on Bash (plywood#19).
|
|
3
|
+
// Written by `plywood guard install`; `plywood guard install` writes it again.
|
|
4
|
+
// It blocks a command line that runs `pulumi up`, `pulumi destroy` or
|
|
5
|
+
// `plunge up` (and `update`, their alias): an agent opens a pull request,
|
|
6
|
+
// and the update runs after a person approves its digest. Exit 2 is Claude
|
|
7
|
+
// Code's code for a blocked call; its stderr goes back to the agent.
|
|
8
|
+
|
|
9
|
+
const GUARDED = { pulumi: ["up", "update", "destroy"], plunge: ["up", "update", "destroy"] };
|
|
10
|
+
const VALUED_GLOBALS = new Set(["-C", "--cwd", "-s", "--stack", "--color", "--config-file", "--logflow", "-v", "--verbose", "--tracing", "--profiling", "--memprofilerate"]);
|
|
11
|
+
const LAUNCHERS = new Set(["env", "npx", "sudo", "command", "exec", "nohup", "time", "xargs"]);
|
|
12
|
+
|
|
13
|
+
function segments(line) {
|
|
14
|
+
return line
|
|
15
|
+
.split(/\|\||&&|[;|\n`]|\$\(|\)/)
|
|
16
|
+
.map((seg) => (seg.match(/'[^']*'|"[^"]*"|\S+/g) ?? []).map((t) => t.replace(/^['"]|['"]$/g, "")))
|
|
17
|
+
.filter((t) => t.length > 0);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The refused engine and subcommand, or undefined. Kept in step with src/guard.ts `guardVerdict`. */
|
|
21
|
+
export function verdict(line) {
|
|
22
|
+
for (const words of segments(line)) {
|
|
23
|
+
let i = 0;
|
|
24
|
+
while (i < words.length && (LAUNCHERS.has(words[i]) || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]) || (words[i - 1] === "env" && words[i].startsWith("-")))) i++;
|
|
25
|
+
const bin = (words[i] ?? "").split("/").pop();
|
|
26
|
+
const refused = GUARDED[bin];
|
|
27
|
+
if (!refused) continue;
|
|
28
|
+
for (let j = i + 1; j < words.length; j++) {
|
|
29
|
+
const w = words[j];
|
|
30
|
+
if (w.startsWith("-")) {
|
|
31
|
+
if (VALUED_GLOBALS.has(w)) j++;
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (refused.includes(w)) return { engine: bin, subcommand: w };
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function main() {
|
|
42
|
+
let input = "";
|
|
43
|
+
for await (const chunk of process.stdin) input += chunk;
|
|
44
|
+
let call;
|
|
45
|
+
try {
|
|
46
|
+
call = JSON.parse(input);
|
|
47
|
+
} catch {
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
const command = call?.tool_input?.command;
|
|
51
|
+
if (typeof command !== "string") return 0;
|
|
52
|
+
const v = verdict(command);
|
|
53
|
+
if (!v) return 0;
|
|
54
|
+
process.stderr.write(`plywood guard: an agent session does not run '${v.engine} ${v.subcommand}'. Open a pull request; plywood previews it, and the update runs after a person approves the digest.\n`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (process.argv[1] && import.meta.url.endsWith("/plywood-guard.mjs") && process.argv[1].endsWith("plywood-guard.mjs")) {
|
|
59
|
+
main().then((code) => process.exit(code));
|
|
60
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
Set up plywood in this repository.
|
|
2
|
+
Read https://intentius.io/plywood/llms.txt first, then
|
|
3
|
+
https://intentius.io/plywood/getting-started/agents/ and follow it.
|
|
4
|
+
Ask me which stacks should wait for an approval before they update.
|
|
5
|
+
Open a pull request with the result.
|
|
6
|
+
Never run `pulumi up`, `plunge up`, `pulumi destroy` or `plywood approve`, override a policy denial, use `--mode up`, or merge; never touch `.chant/allowed_signers` or `chant/lifecycle`.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChantConfig } from "@intentius/chant/config";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Written by plywood init. plywood runs chant's pull request pipeline, and
|
|
5
|
+
* the pulumi lexicon previews and updates each stack listed in
|
|
6
|
+
* plywood.component.ts.
|
|
7
|
+
*/
|
|
8
|
+
export default {
|
|
9
|
+
lexicons: ["pulumi"],
|
|
10
|
+
} satisfies ChantConfig;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# yaml-language-server: $schema=https://intentius.io/plywood/plywood.schema.json
|
|
2
|
+
#
|
|
3
|
+
# plywood.yml, written by plywood init. init reads each Pulumi.yaml and its
|
|
4
|
+
# Pulumi.<stack>.yaml files, the backend and the forge from the repository,
|
|
5
|
+
# so this file holds only what it could not settle there. Add a key to
|
|
6
|
+
# change a default; every key is at https://intentius.io/plywood/reference/config/
|
|
7
|
+
# Run plywood init again after adding a stack; it keeps this file as it is.
|
|
8
|
+
{{keys}}
|
package/README.md
DELETED