From f06cc38f70da22dc64765bb994e6875acb19e8cd Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 11:21:41 +0200 Subject: [PATCH 1/5] Add the Pulumi stack that runs Prompter on the existing cluster (P-21) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployment code, not a deployment: a Pulumi C# project modeled on Studio's, which is the reference implementation for this cluster. It keeps Studio's conventions deliberately — self-managed file://./state committed to Git, the passphrase secrets provider, and a scripts/set-secrets.sh for the runtime secrets. The one place it departs is ownership. Studio's stack creates the UKS cluster, the NGINX controller, the cert-manager issuer and the upcloud-maxiops StorageClass; this stack looks the cluster up by id and declares nothing cluster-scoped, so everything it creates lives inside the prompter-production namespace and the two stacks cannot collide (D-15). What it provisions: Postgres with pgvector as a single-replica StatefulSet (same image as local compose and the eval workflow), the bot as a single-replica Deployment with the Recreate strategy — the Discord gateway wants exactly one connection — and an ingress that publishes only POST /reindex, leaving /healthz cluster-internal. Probe asymmetry is intentional: readiness uses /healthz (database + gateway), liveness is a TCP check, because /healthz reports unhealthy during a Discord outage and restart-looping would fix nothing while taking the re-index endpoint down with it. Builds clean in the solution; nothing has been applied to the cluster. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- .dockerignore | 1 + .github/workflows/build.yml | 2 + Deployment/Cluster/ExistingCluster.cs | 36 ++++ Deployment/Deployment.csproj | 26 +++ Deployment/Networking/PrompterIngress.cs | 90 +++++++++ Deployment/Networking/PrompterIngressArgs.cs | 49 +++++ Deployment/Program.cs | 98 ++++++++++ Deployment/Pulumi.production.yaml | 28 +++ Deployment/Pulumi.yaml | 10 + Deployment/README.md | 84 ++++++++ Deployment/Services/PrompterDeployment.cs | 170 ++++++++++++++++ Deployment/Services/PrompterDeploymentArgs.cs | 78 ++++++++ Deployment/Storage/PostgresDeployment.cs | 182 ++++++++++++++++++ Deployment/Storage/PostgresDeploymentArgs.cs | 57 ++++++ Deployment/scripts/set-secrets.sh | 42 ++++ Directory.Packages.props | 4 + Prompter.slnx | 3 + 17 files changed, 960 insertions(+) create mode 100644 Deployment/Cluster/ExistingCluster.cs create mode 100644 Deployment/Deployment.csproj create mode 100644 Deployment/Networking/PrompterIngress.cs create mode 100644 Deployment/Networking/PrompterIngressArgs.cs create mode 100644 Deployment/Program.cs create mode 100644 Deployment/Pulumi.production.yaml create mode 100644 Deployment/Pulumi.yaml create mode 100644 Deployment/README.md create mode 100644 Deployment/Services/PrompterDeployment.cs create mode 100644 Deployment/Services/PrompterDeploymentArgs.cs create mode 100644 Deployment/Storage/PostgresDeployment.cs create mode 100644 Deployment/Storage/PostgresDeploymentArgs.cs create mode 100755 Deployment/scripts/set-secrets.sh diff --git a/.dockerignore b/.dockerignore index 56ce01d..09a438f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -22,4 +22,5 @@ Specs/ Eval/ Planning/ Documentation/ +Deployment/ docker-compose.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9e12435..e53d83e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -11,12 +11,14 @@ on: paths: - "Source/**" - "Specs/**" + - "Deployment/**" pull_request: branches: - "main" paths: - "Source/**" - "Specs/**" + - "Deployment/**" jobs: dotnet-build: diff --git a/Deployment/Cluster/ExistingCluster.cs b/Deployment/Cluster/ExistingCluster.cs new file mode 100644 index 0000000..adb5b1d --- /dev/null +++ b/Deployment/Cluster/ExistingCluster.cs @@ -0,0 +1,36 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using UpCloud.Pulumi.UpCloud; +using K8sProviderArgs = Pulumi.Kubernetes.ProviderArgs; +using KubernetesProvider = Pulumi.Kubernetes.Provider; + +namespace Cratis.Prompter.Deployment.Cluster; + +/// +/// The UpCloud Kubernetes cluster Prompter deploys into — looked up, never created. +/// +/// +/// The cluster belongs to Studio's Pulumi stack (decision D-11/D-15). This stack only resolves its +/// kubeconfig so it can create namespaced resources inside it; it deliberately declares nothing +/// cluster-scoped, which is what keeps the two stacks from fighting over shared state. +/// +/// The UpCloud UKS cluster id to deploy into. +public sealed class ExistingCluster(string clusterId) +{ + /// + /// Gets the kubeconfig of the existing cluster. + /// + public Output Kubeconfig { get; } = GetKubernetesCluster + .Invoke(new GetKubernetesClusterInvokeArgs { Id = clusterId }) + .Apply(cluster => cluster.Kubeconfig); + + /// + /// Creates the Kubernetes provider every resource in this stack is created through. + /// + /// The environment name, used to name the provider. + /// The for the cluster. + public KubernetesProvider CreateProvider(string environment) => + new($"k8s-{environment}", new K8sProviderArgs { KubeConfig = Kubeconfig }); +} diff --git a/Deployment/Deployment.csproj b/Deployment/Deployment.csproj new file mode 100644 index 0000000..080c307 --- /dev/null +++ b/Deployment/Deployment.csproj @@ -0,0 +1,26 @@ + + + + Exe + Cratis.Prompter.Deployment + Cratis.Prompter.Deployment + + false + false + + $(NoWarn);CA1812 + + direct + + + + + + + + + diff --git a/Deployment/Networking/PrompterIngress.cs b/Deployment/Networking/PrompterIngress.cs new file mode 100644 index 0000000..2a272e4 --- /dev/null +++ b/Deployment/Networking/PrompterIngress.cs @@ -0,0 +1,90 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using Pulumi.Kubernetes.Networking.V1; +using Pulumi.Kubernetes.Types.Inputs.Meta.V1; +using Pulumi.Kubernetes.Types.Inputs.Networking.V1; + +namespace Cratis.Prompter.Deployment.Networking; + +/// +/// Public routing for Prompter. +/// +/// +/// The bot dials out to Discord, so nothing about answering needs to be reachable from the internet. The +/// one inbound caller is the Documentation build's re-index webhook, so the ingress exposes exactly that +/// path and nothing else — /healthz stays cluster-internal for the probes to use. The NGINX +/// controller and the letsencrypt-prod ClusterIssuer are cluster-scoped resources owned by Studio's +/// stack; this only references them by name. +/// +public sealed class PrompterIngress +{ + /// + /// The paths published to the internet. Adding the GitHub webhook (BACKLOG P-44) means adding its path + /// here — everything else on the host stays unroutable. + /// + static readonly string[] _publicPaths = ["/reindex"]; + + /// + /// Initializes a new instance of the class. + /// + /// The arguments describing the ingress. + public PrompterIngress(PrompterIngressArgs args) + { + var paths = _publicPaths.Select(path => new HTTPIngressPathArgs + { + Path = path, + PathType = "Exact", + Backend = new IngressBackendArgs + { + Service = new IngressServiceBackendArgs + { + Name = args.ServiceName, + Port = new ServiceBackendPortArgs { Number = args.ServicePort }, + }, + }, + }).ToList(); + + _ = new Ingress( + $"prompter-ingress-{args.Environment}", + new IngressArgs + { + Metadata = new ObjectMetaArgs + { + Name = "prompter-ingress", + Namespace = args.Namespace, + Labels = { ["environment"] = args.Environment }, + Annotations = new InputMap + { + ["cert-manager.io/cluster-issuer"] = "letsencrypt-prod", + + // The one caller posts an empty body a few times a day. A low ceiling costs + // nothing and caps how fast the shared secret can be guessed from outside. + ["nginx.ingress.kubernetes.io/limit-rps"] = "5", + }, + }, + Spec = new IngressSpecArgs + { + IngressClassName = "nginx", + Tls = + [ + new IngressTLSArgs + { + Hosts = [args.Host], + SecretName = $"prompter-tls-{args.Environment}", + }, + ], + Rules = + [ + new IngressRuleArgs + { + Host = args.Host, + Http = new HTTPIngressRuleValueArgs { Paths = paths }, + }, + ], + }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = args.DependsOn }); + } +} diff --git a/Deployment/Networking/PrompterIngressArgs.cs b/Deployment/Networking/PrompterIngressArgs.cs new file mode 100644 index 0000000..006fa77 --- /dev/null +++ b/Deployment/Networking/PrompterIngressArgs.cs @@ -0,0 +1,49 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using KubernetesProvider = Pulumi.Kubernetes.Provider; + +namespace Cratis.Prompter.Deployment.Networking; + +/// +/// Arguments for . +/// +public sealed class PrompterIngressArgs +{ + /// + /// Gets the Kubernetes provider to deploy through. + /// + public required KubernetesProvider Provider { get; init; } + + /// + /// Gets the namespace to deploy into. + /// + public required string Namespace { get; init; } + + /// + /// Gets the environment label. + /// + public required string Environment { get; init; } + + /// + /// Gets the public host name routed to Prompter. A DNS record for it must point at the cluster's + /// existing ingress load balancer. + /// + public required string Host { get; init; } + + /// + /// Gets the name of the Service to route to. + /// + public required string ServiceName { get; init; } + + /// + /// Gets the port to route to. + /// + public required int ServicePort { get; init; } + + /// + /// Gets resources the ingress must be created after. + /// + public InputList DependsOn { get; init; } = []; +} diff --git a/Deployment/Program.cs b/Deployment/Program.cs new file mode 100644 index 0000000..8b38fc6 --- /dev/null +++ b/Deployment/Program.cs @@ -0,0 +1,98 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Cratis.Prompter.Deployment.Cluster; +using Cratis.Prompter.Deployment.Networking; +using Cratis.Prompter.Deployment.Services; +using Cratis.Prompter.Deployment.Storage; +using Pulumi; +using Pulumi.Kubernetes.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Meta.V1; + +// Prompter's slice of the UpCloud UKS cluster Studio's stack owns (decisions D-11 and D-15). Everything +// created here is namespaced: the cluster, its node group, the NGINX ingress controller, cert-manager and +// the `upcloud-maxiops` StorageClass all belong to Studio's stack and are referenced, never declared. +return await Deployment.RunAsync(() => +{ + var config = new Config("prompter-deployment"); + + var environment = config.Require("environment"); + var clusterId = config.Require("clusterId"); + var image = config.Require("prompterImage"); + var host = config.Require("ingressHost"); + var storageClassName = config.Get("storageClassName") ?? "upcloud-maxiops"; + var storageSizeGb = config.GetInt32("postgresStorageSizeGb") ?? 10; + + var postgresPassword = config.RequireSecret("postgresPassword"); + var discordToken = config.RequireSecret("discordToken"); + var anthropicApiKey = config.RequireSecret("anthropicApiKey"); + var voyageApiKey = config.RequireSecret("voyageApiKey"); + var reindexSecret = config.RequireSecret("reindexSecret"); + + var askChannelId = config.Get("askChannelId"); + var helpForumChannelId = config.Get("helpForumChannelId"); + + var namespaceName = $"prompter-{environment}"; + + var cluster = new ExistingCluster(clusterId); + var provider = cluster.CreateProvider(environment); + + var ns = new Namespace( + $"namespace-{environment}", + new NamespaceArgs + { + Metadata = new ObjectMetaArgs + { + Name = namespaceName, + Labels = { ["environment"] = environment }, + }, + }, + new CustomResourceOptions { Provider = provider }); + + var postgres = new PostgresDeployment(new PostgresDeploymentArgs + { + Provider = provider, + Namespace = namespaceName, + Environment = environment, + NamespaceResource = ns, + Password = postgresPassword, + StorageClassName = storageClassName, + StorageSizeGb = storageSizeGb, + }); + + var prompter = new PrompterDeployment(new PrompterDeploymentArgs + { + Provider = provider, + Namespace = namespaceName, + Environment = environment, + NamespaceResource = ns, + Image = image, + ConnectionString = postgres.ConnectionString, + DiscordToken = discordToken, + AnthropicApiKey = anthropicApiKey, + VoyageApiKey = voyageApiKey, + ReindexSecret = reindexSecret, + AskChannelId = askChannelId, + HelpForumChannelId = helpForumChannelId, + DependsOn = { postgres.Resource }, + }); + + _ = new PrompterIngress(new PrompterIngressArgs + { + Provider = provider, + Namespace = namespaceName, + Environment = environment, + Host = host, + ServiceName = PrompterDeployment.Name, + ServicePort = PrompterDeployment.Port, + DependsOn = { prompter.Service }, + }); + + return new Dictionary + { + ["namespace"] = namespaceName, + ["image"] = image, + ["reindexUrl"] = $"https://{host}/reindex", + }; +}); diff --git a/Deployment/Pulumi.production.yaml b/Deployment/Pulumi.production.yaml new file mode 100644 index 0000000..2a443ae --- /dev/null +++ b/Deployment/Pulumi.production.yaml @@ -0,0 +1,28 @@ +config: + prompter-deployment:environment: production + # --- Target cluster ------------------------------------------------------- + # The UKS cluster Studio's stack owns. This stack looks it up by id and creates only namespaced + # resources inside it (D-15). Find the id with `upctl kubernetes list`, or read it from Studio's + # stack output. It is not a secret — it is a UUID that is useless without UpCloud credentials. + prompter-deployment:clusterId: "REPLACE-WITH-UKS-CLUSTER-ID" + # The cluster-scoped StorageClass Studio's stack declares. Referenced, never created. + prompter-deployment:storageClassName: upcloud-maxiops + # --- Public host ---------------------------------------------------------- + # Only POST /reindex is published on it (see Networking/PrompterIngress.cs). A DNS record for this + # name must point at the cluster's existing ingress load balancer before cert-manager can issue. + prompter-deployment:ingressHost: prompter.cratis.studio + # --- Corpus database ------------------------------------------------------ + prompter-deployment:postgresStorageSizeGb: "10" + # --- Image ---------------------------------------------------------------- + # Pinned by deploy-production.yml to the released version being deployed; the value committed here is + # whatever was deployed last. Bootstrap it by hand for the very first `pulumi up`. + prompter-deployment:prompterImage: cratis/prompter:0.0.0 + # --- Discord channels (non-secret ids; omit to disable that surface) ------- + # prompter-deployment:askChannelId: "000000000000000000" + # prompter-deployment:helpForumChannelId: "000000000000000000" + # + # --- Secrets -------------------------------------------------------------- + # postgresPassword, discordToken, anthropicApiKey, voyageApiKey and reindexSecret are set with + # `./scripts/set-secrets.sh` and land here as passphrase-encrypted `secure:` values, which is what + # makes them safe to commit. They are absent until that script runs — `pulumi up` fails fast naming + # the missing key, which is the intended behavior. diff --git a/Deployment/Pulumi.yaml b/Deployment/Pulumi.yaml new file mode 100644 index 0000000..6956b9b --- /dev/null +++ b/Deployment/Pulumi.yaml @@ -0,0 +1,10 @@ +name: prompter-deployment +description: Prompter's workload on the existing UpCloud Kubernetes cluster +runtime: + name: dotnet +# Self-managed state, exactly as Studio does it — the state for this project lives in-repo under ./state +# (a local file backend) and is committed to Git, so no Pulumi Cloud account is involved. Secrets are +# encrypted with the passphrase provider; export PULUMI_CONFIG_PASSPHRASE before any pulumi command. +# See README.md in this folder. +backend: + url: file://./state diff --git a/Deployment/README.md b/Deployment/README.md new file mode 100644 index 0000000..b697976 --- /dev/null +++ b/Deployment/README.md @@ -0,0 +1,84 @@ +# Prompter Deployment + +Pulumi C# project that runs Prompter on the **UpCloud** managed Kubernetes cluster (UKS, zone `no-svg1`, +Norway) that Studio's Pulumi stack owns. + +Two things make this different from a stock Pulumi project — read them before running anything: + +1. **Self-managed state, committed to Git.** There is no Pulumi Cloud account. State lives in-repo under + `state/` (a `file://` backend) and secrets are encrypted with a passphrase. This mirrors Studio, which is + the reference implementation for this cluster. +2. **This stack does not own the cluster.** Studio's stack creates the UKS cluster, its node group, the NGINX + ingress controller, cert-manager's `letsencrypt-prod` ClusterIssuer, the `upcloud-maxiops` StorageClass and + Promtail's log shipping. This stack looks the cluster up by id and creates **only namespaced resources** + inside `prompter-production`. That rule is what keeps two stacks on one cluster from fighting — see + decision [D-15](../Planning/DECISIONS.md). + +## What gets deployed + +```text +UpCloud UKS (owned by Studio's stack) +└── namespace prompter-production + ├── Secret prompter-postgres ← database password + ├── Secret prompter-secrets ← Discord token, Anthropic + Voyage keys, reindex secret, + │ connection string (keys are the Cratis__Prompter__… paths) + ├── StatefulSet postgres (pgvector/pgvector:pg17) + headless Service + 10Gi volume + ├── Deployment prompter (1 replica, Recreate) + ClusterIP Service on 8080 + └── Ingress prompter-ingress ← TLS host, publishes ONLY POST /reindex +``` + +Single replica is a requirement, not sizing: the Discord gateway wants exactly one connection per bot. + +## Stack + +| Project | Stack | Backend | Public host | +|---------|-------|---------|-------------| +| `Deployment/` (`prompter-deployment`) | `production` | `file://./state` | `prompter.cratis.studio` (config) | + +## Configuration + +Non-secret values live in [`Pulumi.production.yaml`](Pulumi.production.yaml) with comments. Secrets are set +with [`scripts/set-secrets.sh`](scripts/set-secrets.sh) and stored as passphrase-encrypted `secure:` values in +the same file, which is what makes it safe to commit. + +| Key | Kind | Notes | +|---|---|---| +| `clusterId` | config | UKS cluster id to deploy into (`upctl kubernetes list`) | +| `ingressHost` | config | Public host; needs a DNS record at the cluster load balancer | +| `prompterImage` | config | Pinned by the deploy workflow to `cratis/prompter:` | +| `storageClassName` | config | Defaults to `upcloud-maxiops` (Studio's StorageClass) | +| `postgresStorageSizeGb` | config | Volumes can grow, never shrink | +| `askChannelId`, `helpForumChannelId` | config | Discord channel ids; omit to disable that surface | +| `postgresPassword` | secret | `openssl rand -base64 32` | +| `discordToken` | secret | From the Discord application | +| `anthropicApiKey`, `voyageApiKey` | secret | The two model providers | +| `reindexSecret` | secret | `openssl rand -hex 32`; the Documentation build sends it as `X-Reindex-Secret` | + +## First deploy + +```bash +cd Deployment +export PULUMI_CONFIG_PASSPHRASE=... # pick one; store it in a password manager + repo secret +export UPCLOUD_TOKEN=... # or UPCLOUD_USERNAME + UPCLOUD_PASSWORD + +pulumi stack init production # creates local state under ./state +# fill in clusterId + ingressHost in Pulumi.production.yaml first +export POSTGRES_PASSWORD=... DISCORD_TOKEN=... ANTHROPIC_API_KEY=... VOYAGE_API_KEY=... REINDEX_SECRET=... +./scripts/set-secrets.sh +pulumi preview # review +pulumi up +git add Deployment/state Deployment/Pulumi.production.yaml && git commit -m "Update production state" +``` + +Then index the corpus once — `POST /reindex` with the secret, or run the image's `index` mode as a one-off +job — and install the Discord application per [`DISCORD_INTEGRATION.md`](../Planning/DISCORD_INTEGRATION.md). + +## CI/CD + +`Deploy - Production` (`.github/workflows/deploy-production.yml`) pins `prompterImage` to the released +version, runs `pulumi up` against the file backend, and commits the updated state back to `main` with +`[skip ci]`. Publish calls it automatically after pushing the image; `workflow_dispatch` redeploys any +version by hand. It needs two repository secrets: `PULUMI_CONFIG_PASSPHRASE` and `UPCLOUD_TOKEN`. + +**Documentation changes never deploy anything** — they trigger a re-index. See +[`CONTENT_AND_FRESHNESS.md`](../Planning/CONTENT_AND_FRESHNESS.md). diff --git a/Deployment/Services/PrompterDeployment.cs b/Deployment/Services/PrompterDeployment.cs new file mode 100644 index 0000000..a01b535 --- /dev/null +++ b/Deployment/Services/PrompterDeployment.cs @@ -0,0 +1,170 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using Pulumi.Kubernetes.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Apps.V1; +using Pulumi.Kubernetes.Types.Inputs.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Meta.V1; +using K8sDeployment = Pulumi.Kubernetes.Apps.V1.Deployment; + +namespace Cratis.Prompter.Deployment.Services; + +/// +/// The Prompter bot itself: a single-replica Deployment plus the ClusterIP Service the ingress routes to. +/// +/// +/// Single replica is a requirement, not a sizing choice — the Discord gateway wants exactly one connection +/// per bot, so a second pod would double every answer. Recreate makes the rollout hand the gateway +/// over cleanly instead of briefly running two. +/// +public sealed class PrompterDeployment +{ + /// + /// The name of the workload and its Service. + /// + public const string Name = "prompter"; + + /// + /// The port Kestrel serves /healthz and /reindex on (the Dockerfile's EXPOSE). + /// + public const int Port = 8080; + + const string SecretName = "prompter-secrets"; + + /// + /// Initializes a new instance of the class. + /// + /// The arguments describing the deployment. + public PrompterDeployment(PrompterDeploymentArgs args) + { + var labels = new InputMap + { + ["app"] = Name, + ["environment"] = args.Environment, + }; + + // Every secret the bot needs, in one Secret. The keys are the configuration paths themselves, so the + // mapping from `Cratis:Prompter:…` to environment variable is readable at a glance in `kubectl`. + var secret = new Secret( + $"{SecretName}-{args.Environment}", + new SecretArgs + { + Metadata = new ObjectMetaArgs + { + Name = SecretName, + Namespace = args.Namespace, + Labels = labels, + }, + StringData = new InputMap + { + ["Cratis__Prompter__ConnectionString"] = args.ConnectionString, + ["Cratis__Prompter__Discord__Token"] = args.DiscordToken, + ["Cratis__Prompter__Anthropic__ApiKey"] = args.AnthropicApiKey, + ["Cratis__Prompter__Voyage__ApiKey"] = args.VoyageApiKey, + ["Cratis__Prompter__ReindexSecret"] = args.ReindexSecret, + }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = [args.NamespaceResource] }); + + var env = new List + { + new() { Name = "DOTNET_ENVIRONMENT", Value = "Production" }, + }; + + if (!string.IsNullOrWhiteSpace(args.AskChannelId)) + { + env.Add(new EnvVarArgs { Name = "Cratis__Prompter__Discord__AskChannelId", Value = args.AskChannelId }); + } + + if (!string.IsNullOrWhiteSpace(args.HelpForumChannelId)) + { + env.Add(new EnvVarArgs { Name = "Cratis__Prompter__Discord__HelpForumChannelId", Value = args.HelpForumChannelId }); + } + + var container = new ContainerArgs + { + Name = Name, + Image = args.Image, + Ports = [new ContainerPortArgs { ContainerPortValue = Port, Name = "http" }], + Env = env, + EnvFrom = [new EnvFromSourceArgs { SecretRef = new SecretEnvSourceArgs { Name = SecretName } }], + + // Readiness uses /healthz, which checks the database *and* the gateway connection — exactly the + // question "should traffic reach this pod". Liveness deliberately does not: /healthz reports + // unhealthy during a Discord outage, and restarting the pod in a loop would neither fix Discord + // nor let the re-index endpoint keep working. A TCP check asks the only question liveness + // should ask — is the process still there. + ReadinessProbe = new ProbeArgs + { + HttpGet = new HTTPGetActionArgs { Path = "/healthz", Port = Port }, + InitialDelaySeconds = 10, + PeriodSeconds = 15, + FailureThreshold = 3, + }, + LivenessProbe = new ProbeArgs + { + TcpSocket = new TCPSocketActionArgs { Port = Port }, + InitialDelaySeconds = 30, + PeriodSeconds = 30, + FailureThreshold = 3, + }, + Resources = new ResourceRequirementsArgs + { + Requests = { ["cpu"] = "100m", ["memory"] = "256Mi" }, + Limits = { ["cpu"] = "1", ["memory"] = "512Mi" }, + }, + }; + + var deployment = new K8sDeployment( + $"{Name}-{args.Environment}", + new DeploymentArgs + { + Metadata = new ObjectMetaArgs + { + Name = Name, + Namespace = args.Namespace, + Labels = labels, + }, + Spec = new DeploymentSpecArgs + { + Replicas = 1, + Selector = new LabelSelectorArgs { MatchLabels = labels }, + Strategy = new DeploymentStrategyArgs { Type = "Recreate" }, + Template = new PodTemplateSpecArgs + { + Metadata = new ObjectMetaArgs { Labels = labels }, + Spec = new PodSpecArgs { Containers = [container] }, + }, + }, + }, + new CustomResourceOptions + { + Provider = args.Provider, + DependsOn = args.DependsOn.Concat([secret, args.NamespaceResource]), + }); + + Service = new Service( + $"{Name}-service-{args.Environment}", + new ServiceArgs + { + Metadata = new ObjectMetaArgs + { + Name = Name, + Namespace = args.Namespace, + Labels = labels, + }, + Spec = new ServiceSpecArgs + { + Selector = labels, + Ports = [new ServicePortArgs { Port = Port, TargetPort = Port, Name = "http" }], + }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = [deployment] }); + } + + /// + /// Gets the Service the ingress routes to. + /// + public Service Service { get; } +} diff --git a/Deployment/Services/PrompterDeploymentArgs.cs b/Deployment/Services/PrompterDeploymentArgs.cs new file mode 100644 index 0000000..6a9b782 --- /dev/null +++ b/Deployment/Services/PrompterDeploymentArgs.cs @@ -0,0 +1,78 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using KubernetesProvider = Pulumi.Kubernetes.Provider; + +namespace Cratis.Prompter.Deployment.Services; + +/// +/// Arguments for . +/// +public sealed class PrompterDeploymentArgs +{ + /// + /// Gets the Kubernetes provider to deploy through. + /// + public required KubernetesProvider Provider { get; init; } + + /// + /// Gets the namespace to deploy into. + /// + public required string Namespace { get; init; } + + /// + /// Gets the environment label. + /// + public required string Environment { get; init; } + + /// + /// Gets the resource the namespace is created by. + /// + public required Resource NamespaceResource { get; init; } + + /// + /// Gets the image to run, pinned to a released version by the deploy workflow. + /// + public required string Image { get; init; } + + /// + /// Gets the connection string for the corpus database. + /// + public required Output ConnectionString { get; init; } + + /// + /// Gets the Discord bot token. + /// + public required Output DiscordToken { get; init; } + + /// + /// Gets the Anthropic API key used to generate answers. + /// + public required Output AnthropicApiKey { get; init; } + + /// + /// Gets the Voyage API key used to embed the corpus and queries. + /// + public required Output VoyageApiKey { get; init; } + + /// + /// Gets the shared secret that authorizes POST /reindex. + /// + public required Output ReindexSecret { get; init; } + + /// + /// Gets the id of the channel where plain messages are treated as questions, if configured. + /// + public string? AskChannelId { get; init; } + + /// + /// Gets the id of the help forum channel new threads are auto-answered in, if configured. + /// + public string? HelpForumChannelId { get; init; } + + /// + /// Gets resources this deployment must be created after — the database it connects to. + /// + public InputList DependsOn { get; init; } = []; +} diff --git a/Deployment/Storage/PostgresDeployment.cs b/Deployment/Storage/PostgresDeployment.cs new file mode 100644 index 0000000..96e8574 --- /dev/null +++ b/Deployment/Storage/PostgresDeployment.cs @@ -0,0 +1,182 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using Pulumi.Kubernetes.Apps.V1; +using Pulumi.Kubernetes.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Apps.V1; +using Pulumi.Kubernetes.Types.Inputs.Core.V1; +using Pulumi.Kubernetes.Types.Inputs.Meta.V1; + +namespace Cratis.Prompter.Deployment.Storage; + +/// +/// Postgres with pgvector, as a single-replica StatefulSet with a persistent volume. +/// +/// +/// In-cluster rather than managed, per D-11: it mirrors the MongoDB precedent on this cluster, and the +/// corpus is fully rebuildable from cratis.io, so the only data worth protecting is the (anonymous) +/// interaction log. Backups are therefore deliberately out of scope for the first cut — see the +/// operations table in Planning/DEPLOYMENT.md. +/// +public sealed class PostgresDeployment +{ + /// + /// The name of the workload, its governing Service, and the in-cluster DNS name clients connect to. + /// + public const string Name = "postgres"; + + /// + /// The port Postgres listens on. + /// + public const int Port = 5432; + + const string DatabaseName = "prompter"; + const string UserName = "prompter"; + const string SecretName = "prompter-postgres"; + const string PasswordKey = "password"; + + /// + /// Initializes a new instance of the class. + /// + /// The arguments describing the deployment. + public PostgresDeployment(PostgresDeploymentArgs args) + { + var labels = new InputMap + { + ["app"] = Name, + ["environment"] = args.Environment, + }; + + var secret = new Secret( + $"{SecretName}-{args.Environment}", + new SecretArgs + { + Metadata = new ObjectMetaArgs + { + Name = SecretName, + Namespace = args.Namespace, + Labels = labels, + }, + StringData = new InputMap { [PasswordKey] = args.Password }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = [args.NamespaceResource] }); + + // Headless: a single-replica StatefulSet needs a governing Service, and clients resolve the pod + // through the same name. There is no load balancing to do with one replica. + var service = new Service( + $"{Name}-service-{args.Environment}", + new ServiceArgs + { + Metadata = new ObjectMetaArgs + { + Name = Name, + Namespace = args.Namespace, + Labels = labels, + }, + Spec = new ServiceSpecArgs + { + ClusterIP = "None", + Selector = labels, + Ports = [new ServicePortArgs { Port = Port, TargetPort = Port, Name = "postgres" }], + }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = [args.NamespaceResource] }); + + var container = new ContainerArgs + { + Name = Name, + Image = args.Image, + Ports = [new ContainerPortArgs { ContainerPortValue = Port, Name = "postgres" }], + Env = + [ + new EnvVarArgs { Name = "POSTGRES_DB", Value = DatabaseName }, + new EnvVarArgs { Name = "POSTGRES_USER", Value = UserName }, + new EnvVarArgs + { + Name = "POSTGRES_PASSWORD", + ValueFrom = new EnvVarSourceArgs + { + SecretKeyRef = new SecretKeySelectorArgs { Name = SecretName, Key = PasswordKey }, + }, + }, + + // The volume is mounted at the data directory itself, which on a fresh UpCloud volume is + // non-empty (lost+found) and would make initdb refuse. A subdirectory sidesteps that. + new EnvVarArgs { Name = "PGDATA", Value = "/var/lib/postgresql/data/pgdata" }, + ], + VolumeMounts = [new VolumeMountArgs { Name = "data", MountPath = "/var/lib/postgresql/data" }], + ReadinessProbe = new ProbeArgs + { + Exec = new ExecActionArgs { Command = ["pg_isready", "-U", UserName, "-d", DatabaseName] }, + InitialDelaySeconds = 5, + PeriodSeconds = 10, + }, + LivenessProbe = new ProbeArgs + { + Exec = new ExecActionArgs { Command = ["pg_isready", "-U", UserName, "-d", DatabaseName] }, + InitialDelaySeconds = 30, + PeriodSeconds = 30, + FailureThreshold = 6, + }, + Resources = new ResourceRequirementsArgs + { + Requests = { ["cpu"] = "100m", ["memory"] = "256Mi" }, + Limits = { ["cpu"] = "1", ["memory"] = "1Gi" }, + }, + }; + + Resource = new StatefulSet( + $"{Name}-{args.Environment}", + new StatefulSetArgs + { + Metadata = new ObjectMetaArgs + { + Name = Name, + Namespace = args.Namespace, + Labels = labels, + }, + Spec = new StatefulSetSpecArgs + { + ServiceName = Name, + Replicas = 1, + Selector = new LabelSelectorArgs { MatchLabels = labels }, + Template = new PodTemplateSpecArgs + { + Metadata = new ObjectMetaArgs { Labels = labels }, + Spec = new PodSpecArgs { Containers = [container] }, + }, + VolumeClaimTemplates = + [ + new PersistentVolumeClaimArgs + { + Metadata = new ObjectMetaArgs { Name = "data", Namespace = args.Namespace }, + Spec = new PersistentVolumeClaimSpecArgs + { + AccessModes = ["ReadWriteOnce"], + StorageClassName = args.StorageClassName, + Resources = new VolumeResourceRequirementsArgs + { + Requests = { ["storage"] = $"{args.StorageSizeGb}Gi" }, + }, + }, + }, + ], + }, + }, + new CustomResourceOptions { Provider = args.Provider, DependsOn = [secret, service] }); + + ConnectionString = args.Password.Apply(password => + $"Host={Name};Port={Port};Database={DatabaseName};Username={UserName};Password={password}"); + } + + /// + /// Gets the StatefulSet, so dependents can order themselves after it. + /// + public StatefulSet Resource { get; } + + /// + /// Gets the Npgsql connection string the bot connects with. + /// + public Output ConnectionString { get; } +} diff --git a/Deployment/Storage/PostgresDeploymentArgs.cs b/Deployment/Storage/PostgresDeploymentArgs.cs new file mode 100644 index 0000000..a002aff --- /dev/null +++ b/Deployment/Storage/PostgresDeploymentArgs.cs @@ -0,0 +1,57 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +using Pulumi; +using KubernetesProvider = Pulumi.Kubernetes.Provider; + +namespace Cratis.Prompter.Deployment.Storage; + +/// +/// Arguments for . +/// +public sealed class PostgresDeploymentArgs +{ + /// + /// Gets the Kubernetes provider to deploy through. + /// + public required KubernetesProvider Provider { get; init; } + + /// + /// Gets the namespace to deploy into. + /// + public required string Namespace { get; init; } + + /// + /// Gets the environment label. + /// + public required string Environment { get; init; } + + /// + /// Gets the resource the namespace is created by, so the database is never created before it. + /// + public required Resource NamespaceResource { get; init; } + + /// + /// Gets the password for the prompter database role. + /// + public required Output Password { get; init; } + + /// + /// Gets the name of the storage class volumes are provisioned from. This references a cluster-scoped + /// StorageClass owned by Studio's stack (upcloud-maxiops) — it is never created here. + /// + public required string StorageClassName { get; init; } + + /// + /// Gets the image to run. The corpus needs the vector extension, so this is a pgvector build + /// rather than stock Postgres — the same image the local docker-compose.yml and the eval + /// workflow use, so all three environments agree. + /// + public string Image { get; init; } = "pgvector/pgvector:pg17"; + + /// + /// Gets the size of the data volume in gigabytes. The corpus is ~20k chunks with 1024-dimension + /// embeddings, so this is generous; it can only ever grow (UpCloud volumes expand, never shrink). + /// + public int StorageSizeGb { get; init; } = 10; +} diff --git a/Deployment/scripts/set-secrets.sh b/Deployment/scripts/set-secrets.sh new file mode 100755 index 0000000..7a57ac5 --- /dev/null +++ b/Deployment/scripts/set-secrets.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# +# Set Prompter's deployment secrets against the self-managed (passphrase) Pulumi backend. +# Only the variables you export are set — everything else is skipped, so it is safe to re-run to add +# or rotate individual secrets. Values land in Pulumi.production.yaml as passphrase-encrypted +# `secure: v1:...` entries, which is what makes that file safe to commit. +# +# Usage: +# export PULUMI_CONFIG_PASSPHRASE=... # required — the passphrase for this stack +# export POSTGRES_PASSWORD=... # export the secrets you want to set (see below) +# export DISCORD_TOKEN=... +# export ANTHROPIC_API_KEY=... +# export VOYAGE_API_KEY=... +# export REINDEX_SECRET=... +# ./scripts/set-secrets.sh +# +# Generating the two secrets that are ours to invent: +# openssl rand -base64 32 # postgresPassword +# openssl rand -hex 32 # reindexSecret (also goes into the Documentation repo's webhook call) +# +set -euo pipefail + +cd "$(dirname "$0")/.." # Deployment/ +STACK="production" + +: "${PULUMI_CONFIG_PASSPHRASE:?set PULUMI_CONFIG_PASSPHRASE first}" + +set_secret() { + local key="$1" value="$2" + [ -z "$value" ] && return 0 + echo " set prompter-deployment:${key}" + pulumi config set --secret --stack "$STACK" "prompter-deployment:${key}" "$value" +} + +# envvar -> pulumi config key +set_secret postgresPassword "${POSTGRES_PASSWORD:-}" +set_secret discordToken "${DISCORD_TOKEN:-}" +set_secret anthropicApiKey "${ANTHROPIC_API_KEY:-}" +set_secret voyageApiKey "${VOYAGE_API_KEY:-}" +set_secret reindexSecret "${REINDEX_SECRET:-}" + +echo "Done. Review Deployment/Pulumi.${STACK}.yaml — secrets are stored as 'secure: v1:...'." diff --git a/Directory.Packages.props b/Directory.Packages.props index e0ef28c..07f61e6 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -23,6 +23,10 @@ + + + + diff --git a/Prompter.slnx b/Prompter.slnx index 49e0a13..efb8438 100644 --- a/Prompter.slnx +++ b/Prompter.slnx @@ -17,4 +17,7 @@ + + + From 185fa72daeacd983dc427ba1caf90a7432f34690 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 11:21:50 +0200 Subject: [PATCH 2/5] Deploy the released version straight from Publish deploy-production.yml mirrors Studio's: pin the image tag with `pulumi config set` (which preserves the config file's comments, unlike a raw YAML edit), run `pulumi up` against the file backend on the self-hosted cratis runner, then commit the updated state back with [skip ci]. Publish calls it as a workflow_call after publish-docker, so a failed image push can never deploy a tag that does not exist. workflow_dispatch stays available for redeploying or rolling back to any version by hand. Needs two repository secrets that do not exist yet: PULUMI_CONFIG_PASSPHRASE and UPCLOUD_TOKEN. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- .github/workflows/deploy-production.yml | 91 +++++++++++++++++++++++++ .github/workflows/publish.yml | 10 +++ 2 files changed, 101 insertions(+) create mode 100644 .github/workflows/deploy-production.yml diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml new file mode 100644 index 0000000..10f67b6 --- /dev/null +++ b/.github/workflows/deploy-production.yml @@ -0,0 +1,91 @@ +name: Deploy - Production + +# Deploys a released version to the UpCloud Kubernetes cluster (decisions D-11 / D-15). Called +# automatically by Publish once the image is pushed, and available manually to (re)deploy any version. +# Modeled on Studio's Deploy - Production workflow, which is the reference implementation for this +# cluster: same self-managed Pulumi state, same passphrase provider, same commit-state-back step. + +on: + workflow_call: + inputs: + version: + description: "Version (image tag) to deploy, e.g. 0.1.0" + required: true + type: string + workflow_dispatch: + inputs: + version: + description: "Version (image tag) to deploy, e.g. 0.1.0" + required: true + type: string + +concurrency: + group: deploy-production + cancel-in-progress: false + +# The state commit at the end needs write access to the repository. +permissions: + contents: write + +jobs: + deploy: + name: Deploy to Production + runs-on: [self-hosted, linux, cratis] + environment: production + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: "10.0.x" + env: + # The self-hosted runner cannot write to the default /usr/share/dotnet; install into a + # writable, runner-scoped location (setup-dotnet adds it to PATH). + DOTNET_INSTALL_DIR: ${{ runner.temp }}/dotnet + + - name: Install Pulumi CLI + uses: pulumi/actions@v5 + + # Deploy the exact released version by pinning the image tag rather than :latest. + # `pulumi config set` preserves the config file's comments and structure (a raw YAML edit would not). + - name: Pin the image version + working-directory: Deployment + env: + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + VERSION: ${{ inputs.version }} + run: | + set -euo pipefail + pulumi login "file://$(pwd)/state" + pulumi config set --stack production "prompter-deployment:prompterImage" "cratis/prompter:${VERSION}" + + # Self-managed state: Pulumi reads backend.url (file://./state) from Deployment/Pulumi.yaml, so no + # Pulumi Cloud login is required. Secrets are decrypted with the passphrase provider. + - name: Deploy + uses: pulumi/actions@v5 + with: + command: up + stack-name: production + work-dir: Deployment + env: + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + UPCLOUD_TOKEN: ${{ secrets.UPCLOUD_TOKEN }} + UPCLOUD_USERNAME: ${{ secrets.UPCLOUD_TOKEN != '' && '' || secrets.UPCLOUD_USERNAME }} + UPCLOUD_PASSWORD: ${{ secrets.UPCLOUD_TOKEN != '' && '' || secrets.UPCLOUD_PASSWORD }} + + # Persist the pinned image version and the updated Pulumi state back into the repository so state + # travels with Git. [skip ci] prevents the state commit from re-triggering workflows. + - name: Commit updated Pulumi state + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add Deployment/state Deployment/Pulumi.production.yaml + if ! git diff --cached --quiet; then + git commit -m "chore(deploy): deploy ${{ inputs.version }} and update production Pulumi state [skip ci]" + git push origin HEAD:main + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index f49de8b..93de93b 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -65,3 +65,13 @@ jobs: cratis/prompter:latest build-args: | VERSION=${{ needs.release.outputs.version }} + + # Roll the version that was just pushed onto the cluster (D-11 / D-15). Runs only after the image + # exists, so a failed publish can never deploy a tag that is not there. + deploy-production: + if: needs.release.outputs.publish == 'true' + needs: [release, publish-docker] + uses: ./.github/workflows/deploy-production.yml + with: + version: ${{ needs.release.outputs.version }} + secrets: inherit From d6472e418be9b31a6330e50c23d8d328e4153d36 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 11:22:04 +0200 Subject: [PATCH 3/5] Record the release mechanics, D-15, and the docs-gap plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the planning docs were missing or had wrong. Release mechanics: nothing has ever been released. cratis/release-action only cuts a release for a merged PR labeled major/minor/patch, PR #1 carried none, so Publish ran, decided should-publish=false and did nothing — successfully, which is why it went unnoticed. Docker Hub has no cratis/prompter repository and there are no releases. DEPLOYMENT.md now documents the label rule and how to cut the first version. D-15 answers Q-5 and reverses D-11's recommendation: Prompter's Pulumi code lives here, not in Studio's stack. Reading Studio's deploy workflow is what settled it — it pins one version across every Studio image, so a prompterImage entry there would need its own workflow and a cross-repo dispatch anyway, while making every Prompter release re-evaluate MongoDB, Chronicle and the AuthProxies. Marked OPEN: it is their cluster, so the team confirms. Docs-gap flywheel (P-33) rewritten around its two possible feeds, because the idea of Prompter filing docs-gap issues runs straight into D-13 — the interaction log keeps no question text, so there is nothing to mine today. Feed A is a "this should be documented" button (P-45): the click is the consent, the text is forwarded and never stored, and D-13 stands untouched. Feed B needs D-14, added as an OPEN decision recommending A first. P-44 adds the GitHub issues surface — the same answering behind a webhook, silent on refusal — which also closes the loop cheaply, since a refusal on an issue is a docs gap already in a tracker. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- Documentation/guides/deploying.md | 4 +- Planning/BACKLOG.md | 73 ++++++++++++++++---- Planning/CONTENT_AND_FRESHNESS.md | 14 +++- Planning/DECISIONS.md | 71 ++++++++++++++++++- Planning/DEPLOYMENT.md | 111 +++++++++++++++++++++--------- Planning/IMPLEMENTATION_PLAN.md | 11 +-- Planning/SESSION_HANDOVER.md | 47 +++++++++++++ README.md | 1 + 8 files changed, 276 insertions(+), 56 deletions(-) diff --git a/Documentation/guides/deploying.md b/Documentation/guides/deploying.md index 86bfdae..2c28516 100644 --- a/Documentation/guides/deploying.md +++ b/Documentation/guides/deploying.md @@ -5,7 +5,7 @@ description: How Prompter runs in production and where to find the operational r Prompter runs in production on the Cratis UpCloud Kubernetes cluster that also hosts Studio (decision [D-11](https://github.com/Cratis/Prompter/blob/main/Planning/DECISIONS.md)). This page is the map; the -step-by-step runbook - workflows, Pulumi, secrets, backups - is +step-by-step runbook - workflows, Pulumi, secrets, operations - is [DEPLOYMENT.md](https://github.com/Cratis/Prompter/blob/main/Planning/DEPLOYMENT.md), which is the source of truth. @@ -19,7 +19,7 @@ laptop or a cluster. The artifacts are the same at every stage, so nothing is th |---|---|---| | Laptop | `docker compose up -d` plus `dotnet run` | Trying it end to end on a test server, all bot development | | Simple VM (optional) | Smallest UpCloud VM, Docker Compose | An always-on beta before the cluster work lands | -| Cluster | Studio's UpCloud UKS via Pulumi | Production: automated deploys, observability, backups | +| Cluster | The Cratis UpCloud UKS via Pulumi | Production: automated deploys and observability | ## In production diff --git a/Planning/BACKLOG.md b/Planning/BACKLOG.md index 9286044..445e10b 100644 --- a/Planning/BACKLOG.md +++ b/Planning/BACKLOG.md @@ -120,9 +120,15 @@ not to promise live in the parking lot. options; Dockerfile → `aspnet` base + `EXPOSE 8080`; also added `GatewayIntents.Guilds` so forum thread-create (P-13) events arrive. Pure `ReindexAuth`/`ReindexGate` spec-covered (10 facts); endpoints runtime-smoke-tested. **Wiring the Documentation build to call `/reindex` (+ ingress + k8s secret) is M5.3.** -- **P-21** Deploy: join the existing UpCloud UKS cluster per D-11 — Prompter workload + in-cluster - Postgres/pgvector + ingress route + `deploy-production.yml` modeled on Studio's (`Studio/Deployment/` is - the reference). Resolve Q-5 (Pulumi code in Studio's stack vs. this repo) first. +- **P-21** Deploy: join the existing UpCloud UKS cluster per D-11/D-15. **Code shipped 2026-08-06** — a + `Deployment/` Pulumi C# project (own stack, `file://./state`, passphrase secrets, Studio conventions + throughout) provisions namespace + Postgres/pgvector StatefulSet + the bot Deployment/Service + an ingress + route for `/reindex`, and `.github/workflows/deploy-production.yml` pins the image tag and runs `pulumi up`, + called automatically from `publish.yml`. **What remains is credential/team work, not code:** the + `PULUMI_CONFIG_PASSPHRASE`/`UPCLOUD_TOKEN` secrets on this repo, the runtime secrets set via + `Deployment/scripts/set-secrets.sh`, a DNS record for the ingress host, and the first + `pulumi stack init production` + `pulumi up` (see `Deployment/README.md`). Nothing in the stack has been + applied yet — it is unrun infrastructure code. - **P-22** ~~Retention purge job~~ **Done 2026-07-15** (code): `RetentionPurge : BackgroundService` sweeps on a 1-minute initial delay then daily (`PeriodicTimer`), calling `IInteractionLog.PurgeExpired` (deletes interactions older than `RetentionDays`, default 90, on the existing `occurred_at` column), logging the count @@ -138,8 +144,13 @@ not to promise live in the parking lot. config from the AI repo (do not hand-copy rules). - **P-26** ~~Repo settings~~ **Mostly done 2026-07-15**: `Cratis/Prompter` created (public, D-12) and pushed; secrets are **org-level** and confirmed reaching this repo (live `documentation.yml` dispatch succeeded; - Chronicle.Mcp publishes with zero repo secrets). Residue: the Docker Hub `cratis/prompter` repository if - the first publish doesn't auto-create it. + Chronicle.Mcp publishes with zero repo secrets). Residue, re-checked 2026-08-06: **no image has ever been + published** — `hub.docker.com/v2/repositories/cratis/prompter` still 404s and `gh release list` is empty, + because `cratis/release-action` only cuts a release for a merged PR labeled `major`/`minor`/`patch` and the + one merged PR carried none. The first release is a `publish.yml` `workflow_dispatch` with an explicit + version (or the next PR merged with a label) — see the release mechanics in + [`DEPLOYMENT.md`](DEPLOYMENT.md). Deploy secrets `PULUMI_CONFIG_PASSPHRASE` + `UPCLOUD_TOKEN` must exist on + this repo (or at org level) before `deploy-production.yml` can run. ## Content roadmap (design owned by [`CONTENT_AND_FRESHNESS.md`](CONTENT_AND_FRESHNESS.md)) @@ -161,11 +172,22 @@ Phase 1 (docs site) is the v1 corpus and is covered by M1/M5 above. These extend any implementation. - **P-32** Phase 3: **GitHub Discussions / answered issues** across product repos (public data, filtered to resolved). -- **P-33** **Docs-gap flywheel** — weekly digest of refusals + 👎 answers to a maintainer channel; later - auto-file issues in the owning product repo. Prompter as a docs-coverage instrument. **Blocked on - re-introducing question text:** the interaction log is anonymous by [D-13](DECISIONS.md) (no content, no - identity), so this needs a decision record extending D-8/D-13 with a consent notice + narrow retention - before it can mine question text. +- **P-33** **Docs-gap flywheel** — turn refusals and 👎 answers into docs work: a digest to a maintainer + channel, and from there issues in the owning repo. Prompter as a docs-coverage instrument. There are two + possible feeds, and choosing between them is + [D-14](DECISIONS.md#d-14--storing-question-text--open--2026-08-06): + - **Feed A — consent in the moment (via P-45, unblocked).** The asker clicks "this should be documented" on + a refusal; the question text travels straight to the maintainer channel (later: an issue) and is never + persisted. Nothing is stored, so [D-13](DECISIONS.md#d-13--interaction-log-stores-no-personal-data--2026-07-16) + is untouched and no new decision record is needed. + - **Feed B — mined question text (blocked).** Store question text on refusals behind a consent notice and a + narrow retention window, which is what makes counting and clustering possible ("12 people asked this + week"). Needs D-14 ruled first — today the interaction log holds `was_refusal` and a confidence and + nothing else, so there is literally nothing to mine. + Whichever feed, before anything is filed automatically it needs: **clustering** (twelve askers on one topic + is one issue, not twelve), **product routing** to pick the target repo (the same classifier P-30 wants), a + **rate cap**, and a human approval step. Digest-with-one-click-file first; full automation only once the + signal proves clean. Which repo receives the issue is **Q-7**. - **P-34** **Docs MCP server** — expose `IPassages.Search` as an MCP tool alongside Chronicle.Mcp so Claude Code/Copilot/Cursor users share the bot's grounded retrieval. @@ -216,6 +238,26 @@ folded into **P-07**; the hybrid-tuning angle into **P-06**. Remaining actionabl hashes, so `main` has the content but ancestry can't prove per-branch equivalence), so deleting them needs a force delete (`git branch -D`) — held back as a destructive step on branches this session didn't create. +## Post-v1 surfaces (2026-08-06) + +- **P-44** **GitHub issues surface** — Prompter answers newly-opened issues on the Cratis product repos with + the same grounded retrieval it uses on Discord. `IAnswers.For` is surface-agnostic, so this is a new entry + point plus a webhook, not new answering logic: add `POST /github/webhook` to the Kestrel host that already + serves `/healthz` + `/reindex`, verify the `X-Hub-Signature-256` HMAC the same constant-time way + `ReindexAuth` does, answer on `issues.opened`, and post a cited comment. Rules: **silence on refusal** (no + comment beats a hedging comment on an issue tracker), a visible "answered by Prompter — correct me" line, + an opt-out label (`no-prompter`) honored per issue and per repo, and a per-repo rate cap. Start as a GitHub + App installed on one repo behind a confidence threshold; a per-repo `issues.opened` workflow calling the + endpoint is the cheaper spike if App registration is slow. Composes with **P-33**: a refusal on an issue + *is* a docs gap already sitting in a tracker — label it `docs-gap` and the filing problem disappears. + Distinct from **P-32**, which ingests *answered* issues as a retrieval source; the two compose. + Needs the deployed bot to be publicly reachable (M5.3 ingress), so it lands after P-21. +- **P-45** **"Should be documented" report button** — a third button next to 👍/👎, shown on refusals and on + answers that get a 👎: clicking it forwards the question text to the maintainer channel (later: opens an + issue) and persists nothing anywhere. The click *is* the consent, which is why this works under D-13 + unchanged. This is Feed A of **P-33** and the cheapest path to a real docs-gap signal — build it before + touching D-14. Reuses the existing component-interaction handler (`Feedback`) and custom-id scheme. + ## Open questions - **Q-1** Chronicle dogfooding for the interaction log — needs a team ruling (D-6, recommendation: post-v1). @@ -223,10 +265,15 @@ folded into **P-07**; the hybrid-tuning angle into **P-06**. Remaining actionabl pricing ends 2026-08-31. - **Q-3** Is EU-region inference (Vertex/Bedrock) a requirement or a nice-to-have? Affects D-8 wiring only. - **Q-4** Adopt Answer Overflow alongside Prompter (indexes solved threads into Google — complementary)? -- **Q-5** Where does Prompter's Pulumi code live — a workload entry in Studio's `Deployment/` stack - (recommended, matches `studio-llm`/Prologue) or its own `Deployment/` project in this repo (D-11)? +- ~~**Q-5** Where does Prompter's Pulumi code live~~ — **answered 2026-08-06 by + [D-15](DECISIONS.md#d-15--prompters-pulumi-stack-lives-in-this-repo--open--2026-08-06)** + (own `Deployment/` project here, own stack, deploying into the *existing* cluster). Reading Studio's actual + deployment code flipped D-11's recommendation — see D-15 for the evidence. Confirm with the team; the + resource code ports to Studio's stack nearly verbatim if they disagree. - **Q-6** Does UpCloud Managed PostgreSQL support the `vector` extension? Only matters if in-cluster - Postgres proves annoying (D-11 default is in-cluster). + Postgres proves annoying (D-11/D-15 default is in-cluster). +- **Q-7** Where do docs-gap issues get filed — `Cratis/Documentation` (where the docs live) or the owning + product repo (where the maintainers are)? Affects P-33/P-45 routing. ## Parking lot (post-v1, not promised) diff --git a/Planning/CONTENT_AND_FRESHNESS.md b/Planning/CONTENT_AND_FRESHNESS.md index c6cc81d..94eeace 100644 --- a/Planning/CONTENT_AND_FRESHNESS.md +++ b/Planning/CONTENT_AND_FRESHNESS.md @@ -11,7 +11,7 @@ RAG decouples the bot from its knowledge. Prompter is three separately-moving pa | Part | Lives where | Changes when | How it updates | |---|---|---|---| -| **The app** (bot code) | Docker image `cratis/prompter` on the VPS | We change Prompter's code | Release via `publish.yml` → `docker compose pull && up -d` (seconds of downtime, rare) | +| **The app** (bot code) | Docker image `cratis/prompter` on the UKS cluster | We change Prompter's code | Release via `publish.yml` → `deploy-production.yml` pins the tag → `pulumi up` (seconds of downtime, rare) | | **The knowledge** (corpus) | Postgres — chunks + embeddings | Documentation changes | **Re-index, not redeploy**: the indexer upserts only changed chunks; the bot keeps answering throughout — zero downtime, no new image | | **The model** (Claude/Voyage) | Anthropic/Voyage APIs | Vendor releases | Config value (`Anthropic:Model`) — nothing to deploy | @@ -75,6 +75,15 @@ drowns retrieval), the generated API reference (already excluded — poor prose - **The docs-gap flywheel** — Prompter's refusals and 👎-reactions are a *measurement of missing docs*. Weekly digest (start: a pinned message in a maintainer channel; later: auto-filed issues in the right product repo, fitting the existing docs-CI culture). This is how the bot pays the docs team back. + **What feeds it is a privacy decision, not a plumbing one:** the interaction log is anonymous by D-13, so + there is no question text to mine. The unblocked path is a "this should be documented" button on refusals + (BACKLOG P-45) — the click is the consent, the text is forwarded and never stored. Storing question text + for aggregates is D-14, deliberately left open. +- **The GitHub issues surface** — the same grounded answering, on newly-opened issues in the product repos + (BACKLOG P-44): a webhook into the host that already serves `/healthz` and `/reindex`, answering with + citations and staying **silent on refusal**. It also closes the flywheel loop the cheap way — a refusal on + an issue is a docs gap that is already in a tracker, so it just needs a `docs-gap` label rather than a + filing pipeline. Note this is the inverse of Phase 3's *ingestion* of answered issues; the two compose. - **Docs MCP server** — expose `IPassages.Search` as an MCP tool alongside Chronicle.Mcp, so Claude Code/Copilot/Cursor users get the same grounded retrieval the Discord bot uses. One retrieval layer, every AI surface in the ecosystem. (Promoted from parking lot to Phase 2–3 roadmap.) @@ -82,7 +91,8 @@ drowns retrieval), the generated API reference (already excluded — poor prose ## What this means for the pipelines (once Cratis/Prompter is on GitHub) -- `publish.yml` (exists): releases the **app** image on merged PRs — unrelated to content. +- `publish.yml` (exists): releases the **app** image on merged PRs *carrying a release label* — unrelated to + content — and then calls `deploy-production.yml` to roll it out. - Documentation repo `docs-site.yml`: + one `curl -X POST` step to `/reindex` after deploy (team change, P-20). - Prompter repo: nightly `reindex.yml` schedule hitting the same endpoint (safety net, P-05). - Product repos: nothing to change — their existing `build-docs` dispatch already feeds the chain. diff --git a/Planning/DECISIONS.md b/Planning/DECISIONS.md index 02066d5..7900da8 100644 --- a/Planning/DECISIONS.md +++ b/Planning/DECISIONS.md @@ -139,6 +139,73 @@ nothing identifiable takes the log **out of scope entirely** — no lawful-basis attach to anonymous rows, and the public privacy notice can make the strongest possible claim ("we keep no message content and nothing that identifies you"), verifiable in the open source. The retention purge stays as housekeeping (bounding table growth), not as a privacy control. Accepted trade-off: the docs-gap flywheel -([BACKLOG](../BACKLOG.md) P-33) will need question text, so it must **re-introduce content behind its own +([BACKLOG](BACKLOG.md) P-33) will need question text, so it must **re-introduce content behind its own decision record** (consent notice + narrow retention) rather than assuming it is already collected. The -`IInteractionLog` seam is unchanged, so that is an additive change, not a redesign. +`IInteractionLog` seam is unchanged, so that is an additive change, not a redesign. That record is +[D-14](#d-14--storing-question-text--open--2026-08-06). + +## D-14 · Storing question text — OPEN — 2026-08-06 + +**The question [D-13](#d-13--interaction-log-stores-no-personal-data--2026-07-16) deliberately left open.** The +docs-gap flywheel ([BACKLOG](BACKLOG.md) P-33) wants to know *what* people asked that Prompter could not +answer. Nothing knows today: a refusal writes `was_refusal` and a confidence, never the question. Re-admitting +question text is a trivial schema change and a **posture change** — it retires the strongest sentence in the +privacy notice ("we keep no message content") — so it gets its own record rather than riding in on a feature. + +**Options.** + +- **A · Never store it; take consent in the moment.** A "this should be documented" button on refusals + ([BACKLOG](BACKLOG.md) P-45) forwards the question text straight to a maintainer channel or an issue. The + click is the consent and the disclosure; nothing is persisted by us, D-13 stands verbatim, and the signal is + self-filtering — someone cared enough to press it. Costs: only volunteered gaps are seen, and there are no + aggregates ("how many people hit this?"). +- **B · Store question text on refusals only, behind a consent notice + short retention.** Enables counting + and clustering, which is what makes a *weekly digest* better than a stream of one-off reports. Costs: a + channel notice and privacy-page rewrite, a lawful-basis line, a retention window that actually purges (the + existing `RetentionPurge` becomes a privacy control again, not housekeeping), and the open-source claim + weakens from "we keep nothing" to "we keep this, for N days, for this purpose". +- **C · Store everything again** (questions and answers, all interactions). Rejected — D-13's grounds have + not changed; nothing reads it back, and the marginal analytic value over B does not buy back the posture. + +**Recommendation: A now, B only on evidence.** Ship the button, run it for a few weeks, and see whether the +volunteered reports are enough to drive docs work. Reach for B only if the flywheel demonstrably stalls +without aggregates — and then scope it as narrowly as it will go (refused questions only, 30 days, purpose +stated in the channel notice and the privacy page). **Not decided:** the team rules on A-vs-B when the button +has run long enough to argue from data rather than from expectation. + +## D-15 · Prompter's Pulumi stack lives in this repo — OPEN — 2026-08-06 + +**Answers Q-5 and reverses the recommendation in [D-11](#d-11--deploy-on-the-existing-upcloud-cluster-studio-style--2026-07-15)** +(which is otherwise unchanged: UpCloud UKS `no-svg1`, Pulumi C#, Studio conventions). D-11 guessed that +Prompter should become a workload entry in Studio's `Deployment/` stack. Reading Studio's deployment code +(`Studio` @ `4abb7ab7`) rather than describing it from memory turned up three facts that point the other way: + +1. **Studio's deploy workflow pins one version for the whole platform.** `deploy-production.yml` takes a + single `version` input and loops it over `studioImage`/`studioAdminImage`/`studioLobbyImage`/ + `studioCatalogImage`/`llmImage`. There is no per-workload release path, so a `prompterImage` entry would + still need its own workflow in Studio's repo *plus* a cross-repo dispatch (and a PAT) from Prompter's + Publish. The "one place pins every image" benefit D-11 assumed is not actually available to a repo with its + own release cadence. +2. **A shared stack means a shared blast radius.** `pulumi up` there re-evaluates the UKS cluster and node + group, Percona MongoDB with its PBM backups, Chronicle, Core/Admin/Lobby/Catalog, two AuthProxies, the + OAuth2 proxy, Vault, Loki/Grafana/Promtail/OTel and the ingresses. Releasing a Discord bot should not be + able to move any of that, and a Studio release should not carry Prompter's database. +3. **Nothing forces co-location.** Prompter needs no cluster-scoped resources: it looks the existing cluster + up by name through the UpCloud provider's `GetKubernetesCluster` invoke (the same call Studio uses + internally to get its kubeconfig) and creates only namespaced objects inside `prompter-production`. The + shared pieces — NGINX ingress controller, the cert-manager `ClusterIssuer`, Promtail's all-namespace log + shipping — stay owned by Studio and are consumed by reference, which is also how Prompter gets logs in + Grafana for free. + +**So:** a `Deployment/` Pulumi project in *this* repo, project `prompter-deployment`, stack `production`, +deploying into the cluster Studio owns. Everything else follows Studio verbatim, deliberately: self-managed +`file://./state` committed to Git, the passphrase secrets provider, a `scripts/set-secrets.sh`, and a +`deploy-production.yml` that pins the image tag with `pulumi config set`, runs `pulumi up` on the self-hosted +`cratis` runner, and commits the updated state back with `[skip ci]`. + +**Accepted trade-offs:** two stacks now touch one cluster (bounded by the namespace rule above — Prompter +creates nothing cluster-scoped); a second passphrase and state directory to look after; and if the cluster is +ever replaced under a different name, Prompter's `clusterName` config has to be re-pointed. **Reversal is +cheap** and that is the point: every resource class takes a `Provider` + `Namespace` exactly like Studio's own +services do, so moving them into `Studio/Deployment/Services` later is a `Program.cs` wiring change, not a +rewrite. **Open** until the team confirms, because it is their cluster. diff --git a/Planning/DEPLOYMENT.md b/Planning/DEPLOYMENT.md index 89a72f5..fa539dc 100644 --- a/Planning/DEPLOYMENT.md +++ b/Planning/DEPLOYMENT.md @@ -1,9 +1,15 @@ # Deployment — production runbook -How Prompter runs in production: **on the existing UpCloud Kubernetes cluster that runs Studio** (decision -D-11), following Studio's deployment conventions. Implementation order is -[`IMPLEMENTATION_PLAN.md`](IMPLEMENTATION_PLAN.md) M5. Study `Studio/Deployment/` and -`Studio/Documentation/deployment/` before touching anything — that repo is the reference implementation. +How Prompter runs in production: **on the existing UpCloud Kubernetes cluster that runs Studio** (decisions +D-11 and D-15), following Studio's deployment conventions. Implementation order is +[`IMPLEMENTATION_PLAN.md`](IMPLEMENTATION_PLAN.md) M5. `Studio/Deployment/` and +`Studio/Documentation/deployment/` are the reference implementation — this stack was written from them. + +> **Status (2026-08-06):** the infrastructure code exists and compiles — [`Deployment/`](../Deployment/README.md) +> (Pulumi C#) plus `deploy-production.yml`, called from Publish. **Nothing has been applied yet, and no image +> has ever been published**: Docker Hub has no `cratis/prompter` repository and the repo has no releases, +> because `cratis/release-action` only cuts a release for a merged PR labeled `major`/`minor`/`patch` and the +> one merged PR carried none. Read "Cutting the first release" below before anything else. ## The staging ladder — you don't need the cluster to try it @@ -26,14 +32,21 @@ The artifacts are identical at every stage (same image, same compose file locall Prompter joins the **UpCloud UKS cluster** (region `no-svg1`, Norway) that Studio's Pulumi stack manages: -- **The bot** — one k8s Deployment (single replica; the Discord gateway wants exactly one connection) using - Studio's `SimpleWorkload` pattern, image `cratis/prompter` (or the private registry, matching however - `studio-llm` images are hosted). Exposes `GET /healthz` (liveness/readiness probes) and `POST /reindex` - (shared secret) — the reindex route published through the existing ingress/load balancer so the - Documentation build can reach it. -- **Postgres + pgvector** — in-cluster StatefulSet with a persistent volume, mirroring how the cluster - already runs MongoDB, with backups to UpCloud Object Storage the same way MongoDB's S3 backups are wired. - (Alternative: UpCloud Managed PostgreSQL if it supports the `vector` extension — verify before choosing; +Everything lives in its own namespace, `prompter-production`, and **nothing cluster-scoped is declared** — +the cluster, node group, NGINX controller, cert-manager issuer, `upcloud-maxiops` StorageClass and Promtail +all belong to Studio's stack and are referenced by name (D-15). + +- **The bot** — one k8s Deployment, single replica with the `Recreate` strategy (the Discord gateway wants + exactly one connection, so two pods would double every answer), image `cratis/prompter:` from + public Docker Hub — no pull secret needed. Readiness probes `GET /healthz` (database + gateway); + **liveness deliberately does not** — `/healthz` goes unhealthy during a Discord outage, and restarting the + pod in a loop would neither fix Discord nor keep the re-index endpoint alive, so liveness is a TCP check. + `POST /reindex` is the only path published through the existing ingress/load balancer; `/healthz` stays + cluster-internal. +- **Postgres + pgvector** — in-cluster single-replica StatefulSet (`pgvector/pgvector:pg17`, the same image + local compose and the eval workflow use) with a `upcloud-maxiops` volume, mirroring how the cluster already + runs MongoDB. Backups are not wired in the first cut — see the operations table below for why, and for when + that stops being true. (Alternative: UpCloud Managed PostgreSQL if it supports the `vector` extension — Q-6; in-cluster is the recommendation because it matches the MongoDB precedent and the corpus is rebuildable.) - **Observability for free** — logs flow into the existing Loki/Grafana via Promtail; add a simple Grafana panel (questions/day, refusal rate) once interactions accumulate. @@ -42,18 +55,45 @@ Being in `no-svg1` also strengthens the GDPR story from D-8: all stored data (in Norway on an EU-jurisdiction provider; the only external processors remain the Anthropic API (answers) and Voyage (embedding text of public docs). +## Release mechanics — how a version comes into existence + +`publish.yml` runs on every closed pull request, but it only *releases* something under conditions worth +knowing, because the first attempt silently produced nothing: + +- **A merged PR labeled `major`, `minor` or `patch`** → `cratis/release-action` computes the next semantic + version from the latest release (or the highest existing tag), creates the GitHub release, and sets + `should-publish=true`, which is what gates the Docker build and the deploy. +- **A merged PR with no such label** → no release, no image, no deploy. The workflow still runs and still + reports success. This is what happened to PR #1 and why Docker Hub has no repository yet. +- **A PR that was closed without being merged** → never releases, whatever labels it carries. +- **`workflow_dispatch` with an explicit `version`** → releases that version directly. This is the manual + path, and the simplest way to cut the very first one. + +### Cutting the first release + +1. Confirm the Docker Hub credentials reach this repo (they are org-level and proven by Chronicle.Mcp). + The `cratis/prompter` repository does not exist yet — the first push creates it if the account allows + auto-create; otherwise create it by hand first. +2. Add the deploy secrets `PULUMI_CONFIG_PASSPHRASE` and `UPCLOUD_TOKEN` (repo or org level) — without them + the deploy job fails after a successful publish. +3. Run **Publish** with `workflow_dispatch`, version `0.1.0` (or merge a PR labeled `minor`). +4. Publish builds and pushes `cratis/prompter:0.1.0` + `:latest`, then calls **Deploy - Production** with + that version. + ## Deploy flow (mirrors Studio's) -1. **Release the app**: merged PR → `publish.yml` builds and pushes the versioned image (exactly as today). -2. **Deploy the version**: a `deploy-production.yml` modeled on Studio's — `workflow_call` from Publish + - manual `workflow_dispatch(version)` — pins the image tag with `pulumi config set` and runs `pulumi up`, - then commits the updated self-managed Pulumi state back to the repo (`file://./state`, passphrase - provider, `PULUMI_CONFIG_PASSPHRASE` + `UPCLOUD_TOKEN` secrets — same secret names as Studio). -3. **Where the Pulumi code lives** is Q-5 (open): either a `Deployment/` project in this repo targeting the - existing cluster, or a `prompterImage` entry in Studio's `Deployment/` stack next to `llmImage` / - `prologueApiImage`. Recommendation in D-11: **join Studio's stack** — that is the established pattern for - platform services on this cluster, one place pins every image. Revisit if Prompter's release cadence needs - to decouple. +1. **Release the app**: `publish.yml` builds and pushes the versioned image (above). +2. **Deploy the version**: [`deploy-production.yml`](../.github/workflows/deploy-production.yml) — + `workflow_call` from Publish + manual `workflow_dispatch(version)` — pins the image tag with + `pulumi config set`, runs `pulumi up` on the self-hosted `cratis` runner, and commits the updated + self-managed Pulumi state back to the repo (`file://./state`, passphrase provider, + `PULUMI_CONFIG_PASSPHRASE` + `UPCLOUD_TOKEN` — the same secret names Studio uses). +3. **Where the Pulumi code lives** was Q-5, and is now answered by **[D-15](DECISIONS.md)**: a `Deployment/` + project **in this repo**, its own stack, deploying into the cluster Studio owns. Reading Studio's actual + deploy workflow is what settled it — it pins one version across every Studio image, so a `prompterImage` + entry there would have needed its own workflow and a cross-repo dispatch anyway, while making every + Prompter release re-evaluate MongoDB, Chronicle and the AuthProxies. The full argument is in D-15; the + stack itself is documented in [`Deployment/README.md`](../Deployment/README.md). Remember the separation that makes this cheap ([`CONTENT_AND_FRESHNESS.md`](CONTENT_AND_FRESHNESS.md)): **app deploys are for code changes only** — documentation changes never redeploy anything; they trigger the @@ -61,23 +101,28 @@ Remember the separation that makes this cheap ([`CONTENT_AND_FRESHNESS.md`](CONT ## One-time setup (P-26, revised for UpCloud) -1. GitHub repo `Cratis/Prompter` + secrets: `DOCKER_USERNAME`/`DOCKER_PASSWORD` (or registry creds matching - Studio's registry), `PAT_DOCUMENTATION`; plus — wherever the Pulumi code lands — access to - `PULUMI_CONFIG_PASSPHRASE` and `UPCLOUD_TOKEN`. -2. First image release via `publish.yml`. -3. Pulumi additions (per Q-5 resolution): Prompter workload + Postgres StatefulSet + ingress route + - config/secrets (`Cratis__Prompter__…` env vars from k8s secrets: Discord token, Anthropic key, Voyage key, - reindex secret, connection string). -4. `pulumi up`, run the first index (`/reindex` or a one-off `index` job), install the Discord app per - [`DISCORD_INTEGRATION.md`](DISCORD_INTEGRATION.md). +1. **Repository secrets** — `DOCKER_USERNAME`/`DOCKER_PASSWORD` and `PAT_DOCUMENTATION` are org-level and + confirmed reaching this repo. Add `PULUMI_CONFIG_PASSPHRASE` (invent one, store it in the password + manager) and `UPCLOUD_TOKEN` (or `UPCLOUD_USERNAME`/`UPCLOUD_PASSWORD`). +2. **First image release** via `publish.yml` — see "Cutting the first release" above. +3. **Stack bootstrap** — in `Deployment/`: fill in `clusterId` (`upctl kubernetes list`) and `ingressHost`, + `pulumi stack init production`, run `scripts/set-secrets.sh` with the five runtime secrets exported, + `pulumi preview`, `pulumi up`, then commit `Deployment/state` + `Deployment/Pulumi.production.yaml`. + Full walkthrough in [`Deployment/README.md`](../Deployment/README.md). +4. **DNS** — point the `ingressHost` record at the cluster's existing ingress load balancer, or cert-manager + cannot complete the ACME challenge and the TLS secret never issues. +5. **First index** — `POST /reindex` with the shared secret (or run the image's `index` mode as a one-off + job), then install the Discord app per [`DISCORD_INTEGRATION.md`](DISCORD_INTEGRATION.md). +6. **Webhook wiring** — add the `/reindex` call to the Documentation repo's deploy job with the same secret. ## Recurring operations | Concern | How | |---|---| -| **App update** | Merge → Publish → deploy workflow pins the new version → `pulumi up` (Studio pattern; badge/state committed) | +| **App update** | Merge a labeled PR → Publish → deploy workflow pins the new version → `pulumi up` (Studio pattern; state committed) | +| **Rollback** | Run **Deploy - Production** by hand with the previous version — the stack pins whatever tag it is given | | **Docs freshness** | `/reindex` webhook from the Documentation build + nightly schedule — no deploys involved | -| **Backups** | Postgres → UpCloud Object Storage, same wiring as the cluster's MongoDB backups; the corpus is rebuildable from cratis.io, so **interactions** are the only data that matters | +| **Backups** | **Not wired yet.** The corpus is rebuildable from cratis.io and the interaction log is anonymous rows (D-13), so losing the volume costs one re-index and some aggregate history — the reason the first cut ships without backups. Add PBM-style object-storage backups alongside the MongoDB precedent if the interaction history ever becomes analysis-critical | | **Monitoring** | k8s probes on `/healthz`; logs in Loki/Grafana (already collected); weekly glance at refusal rate + feedback ratio | | **Secrets rotation** | k8s secrets via the Pulumi stack (passphrase-encrypted config), rotated with `pulumi config set --secret` + `pulumi up` | | **Data subject requests** | Nothing to action: the interaction log stores no personal data or identifier (D-13), so there is no per-user data to export or delete | diff --git a/Planning/IMPLEMENTATION_PLAN.md b/Planning/IMPLEMENTATION_PLAN.md index 90970e4..55e95c4 100644 --- a/Planning/IMPLEMENTATION_PLAN.md +++ b/Planning/IMPLEMENTATION_PLAN.md @@ -171,10 +171,13 @@ Runbook detail in [`DEPLOYMENT.md`](DEPLOYMENT.md). Build order: *Done when:* rows older than `RetentionDays` disappear on schedule (test with a short window). **Shipped as `RetentionPurge` (cadence + resilience spec-covered; the `DELETE` cutoff was live-verified against a throwaway Postgres).** -3. **Deploy (P-21, P-26)** — per [`DEPLOYMENT.md`](DEPLOYMENT.md): GitHub repo + secrets, first `publish.yml` - release, then a Studio-style `deploy-production.yml` pins the image tag and runs `pulumi up` against the - UpCloud UKS cluster (D-11) — bot Deployment + in-cluster Postgres/pgvector — and the bot joins the real - server with channels configured. +3. **Deploy (P-21, P-26)** ✅ **Code shipped 2026-08-06** — per [`DEPLOYMENT.md`](DEPLOYMENT.md) and + [`Deployment/README.md`](../Deployment/README.md): a `Deployment/` Pulumi C# project (own stack, deploying + into the cluster Studio owns — **D-15** answers Q-5) provisions the namespace, a Postgres/pgvector + StatefulSet, the single-replica bot Deployment + Service, and an ingress publishing only `/reindex`; + `deploy-production.yml` pins the image tag, runs `pulumi up` and commits state back, called from Publish. + Builds clean in the solution. **Unrun:** no release has been cut and nothing has been applied — the gates + are the two deploy secrets, DNS, and the five runtime secrets. *Done when:* the bot answers on the real Cratis Discord and survives a pod reschedule (single-replica k8s Deployment, restarted by the cluster). 4. **Privacy notice (P-23)** — pinned message in the server + the `Documentation/index.md` privacy section diff --git a/Planning/SESSION_HANDOVER.md b/Planning/SESSION_HANDOVER.md index 010e28c..78b4685 100644 --- a/Planning/SESSION_HANDOVER.md +++ b/Planning/SESSION_HANDOVER.md @@ -3,6 +3,53 @@ Resume state for anyone (human or agent) continuing work in a fresh session. Newest entry first — append, don't rewrite history. +## 2026-08-06 — Release readiness assessed; deployment stack built; docs-gap + GitHub-issue surfaces planned + +**State:** Branch **`deploy/release-story-and-docs-gap`** off `main` @ `c9ad5ce`, **not pushed**. Release build +**0 warnings**, **278 specs green**. `main` had moved 11 commits since the last entry (workflow bootstrap, +Copilot-instruction sync, package bumps) — the local checkout was fast-forwarded before starting. + +**Release readiness — the finding that matters:** **nothing has ever been released.** `gh release list` is +empty and `hub.docker.com/v2/repositories/cratis/prompter` 404s. Cause: `cratis/release-action` only cuts a +release for a merged PR labeled `major`/`minor`/`patch`; PR #1 carried none, so Publish ran, decided +`should-publish=false`, and did nothing — successfully. The first release is a `publish.yml` +`workflow_dispatch` with an explicit version, or the next PR merged with a label. Written up under "Release +mechanics" in [`DEPLOYMENT.md`](DEPLOYMENT.md). Everything else about v1 is unchanged: code-complete, and +gated on (1) Voyage + Anthropic keys — which also gate **P-07 threshold calibration**, the one remaining +quality decision, since `Answering:MinScore` is still a guess; (2) a Discord app + test server; (3) deploy. + +**Deployment is no longer a plan — it is code.** New [`Deployment/`](../Deployment/README.md) Pulumi C# +project (in the solution, builds clean in Release): looks the existing UKS cluster up by id, then creates only +namespaced resources in `prompter-production` — Postgres/pgvector StatefulSet, the single-replica bot +Deployment + Service, the two Secrets, and an ingress publishing **only** `POST /reindex`. Plus +`.github/workflows/deploy-production.yml` (pins the tag with `pulumi config set`, `pulumi up`, commits state +back with `[skip ci]`) wired as a `workflow_call` from Publish. Studio's `Deployment/` was read properly this +time and copied where it counts: self-managed `file://./state`, passphrase secrets, `set-secrets.sh`, +self-hosted `cratis` runner. + +**Q-5 answered → [D-15](DECISIONS.md) (OPEN, needs the team's nod):** Prompter's Pulumi code lives **here**, +not in Studio's stack — reversing D-11's guess. Evidence: Studio's `deploy-production.yml` pins one version +across every Studio image (so a `prompterImage` entry needs its own workflow + cross-repo dispatch anyway), +and a shared stack would make every Prompter release re-evaluate MongoDB/Chronicle/AuthProxy. Reversal is +cheap by construction — every resource class takes `Provider` + `Namespace` exactly like Studio's own. + +**Nothing has been applied.** The stack is unrun infrastructure code. Gates: `PULUMI_CONFIG_PASSPHRASE` + +`UPCLOUD_TOKEN` repo secrets, the `clusterId`/`ingressHost` config values, a DNS record at the cluster load +balancer, and `scripts/set-secrets.sh` with the five runtime secrets. Backups are deliberately not wired +(corpus is rebuildable; the interaction log is anonymous rows) — recorded in the operations table. + +**Two new surfaces planned (no code):** **P-44** GitHub-issues surface — `POST /github/webhook` on the +existing Kestrel host, answer `issues.opened` with citations, **silence on refusal**, opt-out label, per-repo +cap; distinct from P-32 (which *ingests* answered issues). **P-45** a "this should be documented" button next +to 👍/👎 that forwards the question text and stores nothing — the click is the consent, so it needs no +privacy-posture change. **P-33** was rewritten around those two feeds, and **[D-14](DECISIONS.md) (OPEN)** +records the actual open question: does Prompter store question text at all? Recommendation: A (never store; +consent in the moment) now, B (refusal-only text, consent notice + short retention) only if the button's +signal proves too thin. **Q-7** added: which repo receives a docs-gap issue. + +**Next:** (1) team confirms D-15 and D-14's direction; (2) add the deploy secrets and cut `0.1.0`; (3) keys → +Stage 0 on a test server → P-07 calibration; (4) then `pulumi up`. + ## 2026-07-16 — Review follow-ups: safe subset (P-35, P-37, P-38, P-39, P-40, P-42) on a branch **State:** Branch **`fix/format-preserve-sources`** off `main` @ `99ab61f`, **not pushed / not merged**. Release diff --git a/README.md b/README.md index b49af46..b06b9b5 100644 --- a/README.md +++ b/README.md @@ -166,6 +166,7 @@ API keys are never committed — use environment variables or a git-ignored `Sou - [`Planning/IMPLEMENTATION_PLAN.md`](Planning/IMPLEMENTATION_PLAN.md) — the detailed plan to feature-complete v1. - [`Planning/DISCORD_INTEGRATION.md`](Planning/DISCORD_INTEGRATION.md) — the Discord behavior contract and app-setup runbook. - [`Planning/DECISIONS.md`](Planning/DECISIONS.md) — durable decisions (the name, build-vs-buy, the stack, GDPR posture). +- [`Deployment/README.md`](Deployment/README.md) — the Pulumi stack that runs Prompter on the Cratis UpCloud cluster; [`Planning/DEPLOYMENT.md`](Planning/DEPLOYMENT.md) is the runbook around it. - [`Documentation/architecture.md`](Documentation/architecture.md) — how ingestion, retrieval, and answering fit together. ## ✅ Quality gates From aa6df8a519de1ae3656111d1396892fe86f4e22f Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 11:22:41 +0200 Subject: [PATCH 4/5] Note the two deploy prerequisites that fail quietly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The self-hosted runner label queues indefinitely rather than failing when it is not available to a repository, and a `production` environment with required reviewers pauses the deploy. Both are fine — they just should not be discovered during the first release. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- .github/workflows/deploy-production.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/deploy-production.yml b/.github/workflows/deploy-production.yml index 10f67b6..5335186 100644 --- a/.github/workflows/deploy-production.yml +++ b/.github/workflows/deploy-production.yml @@ -30,7 +30,13 @@ permissions: jobs: deploy: name: Deploy to Production + # The same self-hosted runner Studio deploys from. If that label is not available to this + # repository the job queues forever rather than failing — switch to `ubuntu-latest` in that case + # (the cluster's control plane accepts connections from anywhere, so a hosted runner reaches it; + # the DOTNET_INSTALL_DIR workaround below is then unnecessary but harmless). runs-on: [self-hosted, linux, cratis] + # If the `production` environment carries required reviewers, the deploy pauses for approval — + # which is the desired behavior for a production rollout, but it will not proceed unattended. environment: production steps: From c9a309d5e44dd10a122835d635eb9bcca313a2e5 Mon Sep 17 00:00:00 2001 From: woksin Date: Thu, 6 Aug 2026 11:47:28 +0200 Subject: [PATCH 5/5] Skip the deploy when there is nothing to deploy to The stack has never been applied and its secrets may not exist yet, so calling it from Publish would turn every release red until the cluster side is set up. A `secrets` context cannot be read from a job-level `if`, so a small job checks the two secrets plus an initialized Deployment/state and the deploy is gated on its output. Publishing an image stays green on its own; the skip is reported as a notice naming exactly what is missing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_015LV2femm3ZR47Lk2SscwJb --- .github/workflows/publish.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93de93b..9a3b9da 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,11 +66,39 @@ jobs: build-args: | VERSION=${{ needs.release.outputs.version }} + # Is there anything to deploy to yet? The stack exists in code but has never been applied, and the + # secrets it needs may not be set. A `secrets` context cannot be read from a job-level `if`, so the + # check happens in a step and the deploy is gated on its output — releasing an image stays green + # while the cluster side is still being set up. + deployment-configured: + if: needs.release.outputs.publish == 'true' + needs: [release] + runs-on: ubuntu-latest + outputs: + ready: ${{ steps.check.outputs.ready }} + + steps: + - uses: actions/checkout@v4 + + - name: Check deployment prerequisites + id: check + env: + PULUMI_CONFIG_PASSPHRASE: ${{ secrets.PULUMI_CONFIG_PASSPHRASE }} + UPCLOUD_TOKEN: ${{ secrets.UPCLOUD_TOKEN }} + run: | + set -euo pipefail + if [ -n "${PULUMI_CONFIG_PASSPHRASE}" ] && [ -n "${UPCLOUD_TOKEN}" ] && [ -d Deployment/state ]; then + echo "ready=true" >> "$GITHUB_OUTPUT" + else + echo "ready=false" >> "$GITHUB_OUTPUT" + echo "::notice title=Deploy skipped::The image is published. Deployment needs PULUMI_CONFIG_PASSPHRASE, UPCLOUD_TOKEN and an initialized Deployment/state — see Deployment/README.md." + fi + # Roll the version that was just pushed onto the cluster (D-11 / D-15). Runs only after the image # exists, so a failed publish can never deploy a tag that is not there. deploy-production: - if: needs.release.outputs.publish == 'true' - needs: [release, publish-docker] + if: needs.deployment-configured.outputs.ready == 'true' + needs: [release, publish-docker, deployment-configured] uses: ./.github/workflows/deploy-production.yml with: version: ${{ needs.release.outputs.version }}