Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,5 @@ Specs/
Eval/
Planning/
Documentation/
Deployment/
docker-compose.yml
2 changes: 2 additions & 0 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ on:
paths:
- "Source/**"
- "Specs/**"
- "Deployment/**"
pull_request:
branches:
- "main"
paths:
- "Source/**"
- "Specs/**"
- "Deployment/**"

jobs:
dotnet-build:
Expand Down
97 changes: 97 additions & 0 deletions .github/workflows/deploy-production.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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
# 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:
- 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
38 changes: 38 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,41 @@ jobs:
cratis/prompter:latest
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.deployment-configured.outputs.ready == 'true'
needs: [release, publish-docker, deployment-configured]
uses: ./.github/workflows/deploy-production.yml
with:
version: ${{ needs.release.outputs.version }}
secrets: inherit
36 changes: 36 additions & 0 deletions Deployment/Cluster/ExistingCluster.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The UpCloud Kubernetes cluster Prompter deploys into — looked up, never created.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <param name="clusterId">The UpCloud UKS cluster id to deploy into.</param>
public sealed class ExistingCluster(string clusterId)
{
/// <summary>
/// Gets the kubeconfig of the existing cluster.
/// </summary>
public Output<string> Kubeconfig { get; } = GetKubernetesCluster
.Invoke(new GetKubernetesClusterInvokeArgs { Id = clusterId })
.Apply(cluster => cluster.Kubeconfig);

/// <summary>
/// Creates the Kubernetes provider every resource in this stack is created through.
/// </summary>
/// <param name="environment">The environment name, used to name the provider.</param>
/// <returns>The <see cref="KubernetesProvider"/> for the cluster.</returns>
public KubernetesProvider CreateProvider(string environment) =>
new($"k8s-{environment}", new K8sProviderArgs { KubeConfig = Kubeconfig });
}
26 changes: 26 additions & 0 deletions Deployment/Deployment.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<RootNamespace>Cratis.Prompter.Deployment</RootNamespace>
<AssemblyName>Cratis.Prompter.Deployment</AssemblyName>
<!-- Infrastructure code, not a shipped library: no symbol/source packaging is wanted here. -->
<IncludeSymbols>false</IncludeSymbols>
<IncludeSource>false</IncludeSource>
<!-- Pulumi instantiates resource wrappers for their side effects, so CA1812 (uninstantiated
internal class) does not apply. Everything else stays on. -->
<NoWarn>$(NoWarn);CA1812</NoWarn>
<!-- Audit direct dependencies only. Pulumi 3.107.3 pulls in OpenTelemetry 1.9.0, which carries
two moderate advisories we cannot act on from here (they are the Pulumi SDK's own telemetry
dependency, not ours, and this project never runs outside a deploy job). Auditing direct
references keeps the signal for packages we actually choose. -->
<NuGetAuditMode>direct</NuGetAuditMode>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Pulumi" />
<PackageReference Include="Pulumi.Kubernetes" />
<PackageReference Include="UpCloud.Pulumi.UpCloud" />
</ItemGroup>

</Project>
90 changes: 90 additions & 0 deletions Deployment/Networking/PrompterIngress.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Public routing for Prompter.
/// </summary>
/// <remarks>
/// 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 — <c>/healthz</c> stays cluster-internal for the probes to use. The NGINX
/// controller and the <c>letsencrypt-prod</c> ClusterIssuer are cluster-scoped resources owned by Studio's
/// stack; this only references them by name.
/// </remarks>
public sealed class PrompterIngress
{
/// <summary>
/// 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.
/// </summary>
static readonly string[] _publicPaths = ["/reindex"];

/// <summary>
/// Initializes a new instance of the <see cref="PrompterIngress"/> class.
/// </summary>
/// <param name="args">The arguments describing the ingress.</param>
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<string>
{
["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 });
}
}
49 changes: 49 additions & 0 deletions Deployment/Networking/PrompterIngressArgs.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Arguments for <see cref="PrompterIngress"/>.
/// </summary>
public sealed class PrompterIngressArgs
{
/// <summary>
/// Gets the Kubernetes provider to deploy through.
/// </summary>
public required KubernetesProvider Provider { get; init; }

/// <summary>
/// Gets the namespace to deploy into.
/// </summary>
public required string Namespace { get; init; }

/// <summary>
/// Gets the environment label.
/// </summary>
public required string Environment { get; init; }

/// <summary>
/// Gets the public host name routed to Prompter. A DNS record for it must point at the cluster's
/// existing ingress load balancer.
/// </summary>
public required string Host { get; init; }

/// <summary>
/// Gets the name of the Service to route to.
/// </summary>
public required string ServiceName { get; init; }

/// <summary>
/// Gets the port to route to.
/// </summary>
public required int ServicePort { get; init; }

/// <summary>
/// Gets resources the ingress must be created after.
/// </summary>
public InputList<Resource> DependsOn { get; init; } = [];
}
Loading
Loading