Zum Inhalt springen

Pulumi und TypeScript für Cloud-Infrastruktur

Praxisnahe Anleitung zur Cloud-Infrastruktur mit Pulumi und TypeScript: Ressourcenkomposition, State-Management, Secrets und Infrastruktur-Tests.

4 Min. Lesezeit
Architektur eines Pulumi-Stacks, die zeigt, wie TypeScript-Ressourcendefinitionen in API-Aufrufe des Cloud-Anbieters kompiliert werden

Warum TypeScript für Infrastruktur

YAML und HCL wurden als deklarative Konfigurationssprachen entwickelt. Sie funktionieren gut, bis Bedingungen, Schleifen, Abstraktionen oder Typsicherheit ins Spiel kommen; ab diesem Punkt kämpft man gegen die Sprache, anstatt das eigentliche Infrastrukturproblem zu lösen. Pulumi verwendet echte Programmiersprachen zur Definition von Infrastruktur, wodurch das Typsystem von TypeScript, die IDE-Unterstützung, Test-Frameworks und das Paket-Ökosystem direkt auf Cloud-Ressourcen anwendbar sind.

Dieser Wandel ist nicht nur kosmetischer Natur. Er verändert grundlegend, wie man über Infrastruktur denkt: nicht als statische Konfiguration, sondern als komponierbaren, testbaren Code.

Ressourcen typsicher definieren

tstypescript
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
// ❌ YAML/HCL — no type checking, no autocomplete, string-based references
// resource "aws_s3_bucket" "data" {
//   bucket = "my-data-bucket"
//   acl    = "privte"  ← typo undetected until apply
// }
 
// ✅ TypeScript — type errors caught at compile time
const dataBucket = new aws.s3.Bucket("data-bucket", {
  bucket: "my-data-bucket",
  acl: "private", // Autocomplete shows valid values
  versioning: { enabled: true },
  serverSideEncryptionConfiguration: {
    rule: {
      applyServerSideEncryptionByDefault: {
        sseAlgorithm: "aws:kms",
      },
    },
  },
  lifecycleRules: [
    {
      enabled: true,
      transitions: [
        { days: 30, storageClass: "STANDARD_IA" },
        { days: 90, storageClass: "GLACIER" },
      ],
      expiration: { days: 365 },
    },
  ],
});
 
// Output the bucket ARN — typed as pulumi.Output<string>
export const bucketArn = dataBucket.arn;

Wiederverwendbare Komponenten zusammensetzen

Echte Programmiersprachen erlauben den Aufbau von Abstraktionen. Eine ComponentResource kapselt zusammengehörige Ressourcen in einem wiederverwendbaren Modul mit einer typisierten Schnittstelle, etwas, das in rein deklarativen Tools ohne Templating von Drittanbietern unmöglich ist.

tstypescript
interface WebAppArgs {
  domain: string;
  environment: "staging" | "production";
  containerImage: string;
  cpu: number;
  memory: number;
  desiredCount: number;
  healthCheckPath: string;
}
 
class WebApp extends pulumi.ComponentResource {
  public readonly url: pulumi.Output<string>;
  public readonly serviceName: pulumi.Output<string>;
 
  constructor(
    name: string,
    args: WebAppArgs,
    opts?: pulumi.ComponentResourceOptions
  ) {
    super("custom:WebApp", name, {}, opts);
 
    const cluster = new aws.ecs.Cluster(`${name}-cluster`, {}, { parent: this });
 
    const taskDef = new aws.ecs.TaskDefinition(
      `${name}-task`,
      {
        family: name,
        cpu: String(args.cpu),
        memory: String(args.memory),
        networkMode: "awsvpc",
        requiresCompatibilities: ["FARGATE"],
        containerDefinitions: JSON.stringify([
          {
            name: name,
            image: args.containerImage,
            portMappings: [{ containerPort: 3000 }],
            healthCheck: {
              command: ["CMD-SHELL", `curl -f http://localhost:3000${args.healthCheckPath} || exit 1`],
              interval: 30,
              timeout: 5,
              retries: 3,
            },
          },
        ]),
      },
      { parent: this }
    );
 
    const service = new aws.ecs.Service(
      `${name}-service`,
      {
        cluster: cluster.arn,
        taskDefinition: taskDef.arn,
        desiredCount: args.desiredCount,
        launchType: "FARGATE",
      },
      { parent: this }
    );
 
    this.url = pulumi.interpolate`https://${args.domain}`;
    this.serviceName = service.name;
 
    this.registerOutputs({
      url: this.url,
      serviceName: this.serviceName,
    });
  }
}
 
// Usage — deploy two environments with one component
const staging = new WebApp("api-staging", {
  domain: "staging.api.example.com",
  environment: "staging",
  containerImage: "registry.example.com/api:latest",
  cpu: 256,
  memory: 512,
  desiredCount: 1,
  healthCheckPath: "/health",
});
 
const production = new WebApp("api-production", {
  domain: "api.example.com",
  environment: "production",
  containerImage: "registry.example.com/api:v2.3.1",
  cpu: 1024,
  memory: 2048,
  desiredCount: 3,
  healthCheckPath: "/health",
});

Secrets- und Konfigurationsmanagement

Pulumi verschlüsselt Secrets standardmäßig im State. Konfigurationswerte sind typisiert und stackbezogen, sodass derselbe Code in unterschiedlichen Umgebungen bereitgestellt werden kann, ohne dass die Definitionen mit verstreuten Bedingungen durchsetzt werden müssen.

tstypescript
const config = new pulumi.Config();
 
// Plaintext config
const region = config.require("region");
const environment = config.require("environment");
 
// Encrypted secrets — never stored in plaintext in state
const dbPassword = config.requireSecret("dbPassword");
const apiKey = config.requireSecret("apiKey");
 
// Type-safe configuration objects
interface AppConfig {
  replicas: number;
  logLevel: string;
  features: string[];
}
 
const appConfig = config.requireObject<AppConfig>("app");
 
// Secrets are pulumi.Output<string> — can only be used in resource args
const database = new aws.rds.Instance("main-db", {
  instanceClass: "db.t3.medium",
  allocatedStorage: 20,
  engine: "postgres",
  engineVersion: "16",
  masterUsername: "admin",
  masterPassword: dbPassword, // Encrypted in state
  skipFinalSnapshot: environment !== "production",
});

Infrastrukturcode testen

Da die Infrastruktur in TypeScript vorliegt, lassen sich Unit-Tests schreiben, die Ressourcenkonfigurationen überprüfen, ohne dass tatsächlich etwas bereitgestellt wird. Das Mocking-Framework von Pulumi ersetzt Cloud-API-Aufrufe durch Assertions.

tstypescript
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect, beforeAll } from "vitest";
 
// Mock Pulumi runtime for testing
pulumi.runtime.setMocks({
  newResource: (args) => ({
    id: `${args.name}-id`,
    state: args.inputs,
  }),
  call: (args) => args.inputs,
});
 
describe("WebApp component", () => {
  let app: typeof import("./index");
 
  beforeAll(async () => {
    app = await import("./index");
  });
 
  it("creates ECS service with correct desired count", (done) => {
    pulumi.all([app.production.serviceName]).apply(([name]) => {
      expect(name).toBeDefined();
      done();
    });
  });
 
  it("uses FARGATE launch type", (done) => {
    // Verify resource properties match expectations
    const resources = pulumi.runtime.listResourceOutputs();
    // Assert against collected resources
    done();
  });
});
 
// Policy tests — enforce organizational rules
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
 
new PolicyPack("security-policies", {
  policies: [
    {
      name: "s3-no-public-read",
      description: "S3 buckets must not have public read access",
      enforcementLevel: "mandatory",
      validateResource: validateResourceOfType(
        aws.s3.Bucket,
        (bucket, args, reportViolation) => {
          if (bucket.acl === "public-read" || bucket.acl === "public-read-write") {
            reportViolation("S3 buckets must not be publicly readable");
          }
        }
      ),
    },
    {
      name: "rds-encryption-required",
      description: "RDS instances must have storage encryption enabled",
      enforcementLevel: "mandatory",
      validateResource: validateResourceOfType(
        aws.rds.Instance,
        (instance, args, reportViolation) => {
          if (!instance.storageEncrypted) {
            reportViolation("RDS instances must enable storage encryption");
          }
        }
      ),
    },
  ],
});

Stack-Referenzen für Multi-Stack-Architekturen

Große Infrastrukturen werden in mehrere Stacks aufgeteilt, Netzwerk, Compute, Datenbanken, die jeweils unabhängig verwaltet werden. Stack-Referenzen ermöglichen es einem Stack, Outputs eines anderen zu nutzen, ohne fest codierte Werte zu verwenden.

tstypescript
// Networking stack exports VPC and subnet IDs
export const vpcId = vpc.id;
export const privateSubnetIds = privateSubnets.map((s) => s.id);
 
// Application stack references networking outputs
const networkStack = new pulumi.StackReference("org/networking/production");
const vpcId = networkStack.getOutput("vpcId");
const subnetIds = networkStack.getOutput("privateSubnetIds");
 
const service = new aws.ecs.Service("app", {
  networkConfiguration: {
    subnets: subnetIds as pulumi.Output<string[]>,
    securityGroups: [appSecurityGroup.id],
  },
});

Wichtigste Erkenntnisse

Der Einsatz von TypeScript für Infrastruktur bringt Typsicherheit, IDE-Autovervollständigung, Refactoring-Werkzeuge und echte Abstraktionen, nichts davon ist in YAML oder HCL verfügbar. Typfehler in Ressourcenkonfigurationen werden bereits zur Kompilierzeit erkannt, nicht erst während eines zehnminütigen Apply-Zyklus.

Erstelle wiederverwendbare ComponentResource-Klassen, die zusammengehörige Ressourcen hinter typisierten Schnittstellen kapseln. Das eliminiert Copy-Paste zwischen Umgebungen und sorgt für konsistente Konfigurationen. Nutze das Konfigurationssystem von Pulumi für umgebungsspezifische Werte und seine Secret-Verschlüsselung für Zugangsdaten.

Teste Infrastrukturcode genauso wie Anwendungscode: Unit-Tests für Ressourcenkonfigurationen, Policy-Tests für organisatorische Regeln und Integrationstests für die End-to-End-Bereitstellung des Stacks. Die Investition in testbare Infrastruktur zahlt sich jedes Mal aus, wenn ein Konfigurationsfehler erkannt wird, bevor er in die Produktion gelangt.

Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX