Let me try again. I've got a complex UserData scr...
# typescript
i
Let me try again. I've got a complex UserData script, and cannot get the TailnetKey to output. For a simple UserData it works fine e.g,
Copy code
import * as tailscale from "@pulumi/tailscale";
import * as aws from "@pulumi/aws";

const tn_key = new tailscale.TailnetKey("tailnet-key", {
});

new aws.ec2.Instance(`instance1`, {
  tags: {
    Name: "slartibartfast"
  },
  instanceType: "t3.small",
  ami: "ami-01e2093820bf84df1",
  // NOTE the .apply() is being set on the Input<string> at the top level
  userData: tn_key.key.apply(v => `
#!/bin/sh
curl -fsSL <https://tailscale.com/install.sh> | sh
tailscale up --auth-key=${v}
`),
})
But for a more complex one, where the key needs to be outputted first, the response is always
Calling [toString] on an [Output<T>] is not supported. ...
. e.g.
Copy code
import * as tailscale from "@pulumi/tailscale";
import * as aws from "@pulumi/aws";

const tn_key2 = new tailscale.TailnetKey("tailnet-key", {
});

new aws.ec2.Instance(`instance2`, {
  tags: {
    Name: "slartibartfast2"
  },
  instanceType: "t3.small",
  ami: "ami-01e2093820bf84df1",
  // NOTE the .apply() is inside the string interpolation
  userData: `
#!/bin/sh
curl -fsSL <https://tailscale.com/install.sh> | sh
tailscale up --auth-key=${tn_key2.key.apply(v => v)}
`,
})
Please don't respond is you are simply going to suggest use apply or pulumi.interpolate. The code above is complete, ie. with run if you put it in a index.ts. If you are not 110% sure you answer will solve the problem, please run it on with
pulumi up -d
. If get
ailscale up --auth-key=Calling [toString] on an [Output<T>] is not supported....
, then you have not solved the problem. I have tried many alternatives (multiple versions of creating a custom ComponentResource, putting the tn_key in the dependsOn opts, ...) My guess the issue has something to do how pulumi manages dependancies, but I could really do with some expert help. (@big-piano-35669) Thanks in advance Gary
q
Hey Gary, the reason why the second example does not work is that you cannot use outputs (like the result of
apply
) inside string interpolation. At the time the string interpolation is done the output isn't resolved yet (you can think of it like a promise). I know you explicitly asked for other options, but variations of
apply
(i.e.
pulumi.all
,
pulumi.interpolate
) or turning the
Output
into a promise is what you'll need to do in order to await the result. I'm trying to understand what you're referring to with "But for a more complex one, where the key needs to be outputted first [...]". Can you show an example of what you're trying to achieve conceptually?
l
This is what Florian is referring to:
Copy code
userData: `
#!/bin/sh
curl -fsSL <https://tailscale.com/install.sh> | sh
tailscale up --auth-key=${tn_key2.key.apply(v => v)}
`
The interpolation must happen inside the apply:
Copy code
userData: tn_key2.key.apply(v => {
  return `#!/bin/sh
curl -fsSL <https://tailscale.com/install.sh> | sh tailscale up --auth-key=${v}`;
})
i
Ok, if there are multiple outputs (which need
apply
) looks like the a nested apply is required. Here is a simple example.
Copy code
import * as random from "@pulumi/random";

const r1 = new random.RandomString("random1", {
    length: 16,
});
const r2 = new random.RandomString("random2", {
  length: 16,
});

class BootScript {
  lines: string[] = [];

  sh(line: string) {
    this.lines.push(line);
  }

  compile(): string {
    return this.lines.join("\n")
  }
}

const bs0 = new BootScript()

const bs1 = r1.result.apply(v => {
  bs0.sh(`R1=${v}`)
  return bs0
})

const bs2 = bs1.apply(bs => {
  return r2.result.apply(v => {
    bs.sh(`R2=${v}`)
    return bs
  })
})

export const data = bs2.apply(v => v.compile())
l
Actually, you can use
pulumi.all()
for that. That essentially groups multiple outputs into a single output array containing all the values. https://www.pulumi.com/docs/iac/concepts/inputs-outputs/all/
i
@little-cartoon-10569
all
can’t be used if the applies are in different places in the code base.
l
The Output shenanigans took me a little while to fully grasp as well. One of the things that didn't immediately click for me was:
.apply
returns an
Output
. You can't embed Outputs in strings. Using an
apply
inside a string will never work because of this. Taking the example from your original post, your
${tn_key2.key.apply(v => v)}
is essentially doing nothing, as it's converting an Output into an Output and then embedding it into a string that isn't being interpolated by Pulumi, so Node tries to interpolate it, hence the error. I'm pretty sure the answer actually is just
pulumi.interpolate
in that case. Note that you will not be able to see it in -debug' or preview because, as it's an output, it hasn't resolved yet.
Copy code
import * as aws from "@pulumi/aws"
import * as pulumi from "@pulumi/pulumi"
import * as tailscale from "@pulumi/tailscale"

const tn_key2 = new tailscale.TailnetKey("tailnet-key", {
});

new aws.ec2.Instance(`instance2`, {
  tags: {
    Name: "slartibartfast2"
  },
  instanceType: "t3.small",
  ami: "ami-01e2093820bf84df1",
  // wrapped the entire userdata in a pulumi.interpolate, now Outputs inside the string will be resolved
  userData: pulumi.interpolate`
#!/bin/sh
curl -fsSL <https://tailscale.com/install.sh> | sh
tailscale up --auth-key=${tn_key2.key}
`,
})
For your more recent, complex example, I really advise not trying to modify variables out of the scope of the callback you're in. Pass the Outputs themselves, with as many chained apply's as they need, and then resolve them all in one go when you call
.compile()
. In my experience, if you're trying to
.apply
inside a
.apply
, you need to refactor a little. I'm having a little bit of trouble parsing the requirements here, but if I've understood correctly:
Copy code
import * as random from "@pulumi/random";
import * as pulumi from "@pulumi/pulumi";

const r1 = new random.RandomString("random1", {
    length: 16,
});
const r2 = new random.RandomString("random2", {
  length: 16,
});

class BootScript {
  lines: pulumi.Output<string>[] = [];

  sh(line: pulumi.Output<string>) {
    this.lines.push(line);
  }

  compile(): pulumi.Output<string> {
    return pulumi.all(this.lines).apply(lines => lines.join("\n"));
  }
}

const bs0 = new BootScript()

const bs1 = r1.result.apply(v => {
  return `R1=${v}`
})
bs0.sh(bs1)

// I assume you wanted to wait for bs1 first here so they resolved in the correct order in the array. That won't be an issue here
const bs2 = r2.result.apply(v => {
  return `R2=${v}`
})
bs0.sh(bs2)


export const data = bs0.compile()
Side note: It's very easy to end up with race conditions with this pattern, and just have pulumi hang and not make any progress during a deployment. There are ways to define the equivalent of CDK's 'lazy' variables, but it makes subtle and infuriating bugs. I have a fairly complex 'policy' class that makes use of that to have multiple components contribute to a policy and then collapse them all into optimised statements at the end, and the amount of times I've forgotten to call
.freeze()
and just had Pulumi hang forever is ridiculous
l
You said > Ok, if there are multiple outputs (which need apply) looks like the a nested apply is required. In this case,
all()
can be applied. Any time nested `apply()`s might be needed,
all()
cam be used instead.
i
@late-balloon-24601
the amount of times I've forgotten to call
.freeze()
and just had Pulumi hang forever is ridiculous
What is
freeze
?
@late-balloon-24601, thanks. I've tided it up a bit
Copy code
import * as random from "@pulumi/random";
import * as pulumi from "@pulumi/pulumi";

const r1 = new random.RandomString("random1", {
  length: 16,
});
const r2 = new random.RandomString("random2", {
  length: 16,
});

class BootScript {
  lines: pulumi.Output<string>[] = [];

  sh(line: string): void;
  sh(line: pulumi.Output<string>): void;
  sh(line: pulumi.Output<string> | string): void {
    switch (typeof line) {
      case "string":
        this.lines.push(pulumi.output(line));
        break
      case "object":
        this.lines.push(line);
        break
    }
  }

  compile(): pulumi.Output<string> {
    // could used all instead of output
    return pulumi.output(this.lines).apply(v => v.join("\n"))
  }
}

const bs = new BootScript()

bs.sh("#!/bin/sh")
// apply or interpolate
bs.sh(r1.result.apply(v => `R1=${v}`))
bs.sh(pulumi.interpolate`R2=${r2.result}`)

export const data = bs.compile()
l
.freeze()
was my equivalent to your
.compile()
, except mine is pure evil because I made it so you could add more statements to a policy after you'd already used that policy in a resource, as long as you hadn't called freeze yet. Don't do that 😁
i
except mine is pure evil because I made it so you could add more statements to a policy after you'd already used that policy in a resource
@late-balloon-24601 apologies is I'm stating the obvious, sounds like a fluent api might save you. e.g.
Copy code
interface BSBuilder {
  sh(line: pulumi.Output<string> | string): BSBuilder;
  // remove if compile can't be called multiple times.
  compile(): pulumi.Output<string>
  freeze(): BSFrozen
}

interface BSFrozen {
  compile(): pulumi.Output<string>
}

class BootScript implements BSBuilder {
  lines: pulumi.Output<string>[] = [];

  sh(line: pulumi.Output<string> | string): BSBuilder {
    switch (typeof line) {
      case "string":
        this.lines.push(pulumi.output(line));
        break
      case "object":
        this.lines.push(line);
        break
    }
    return this
  }

  freeze(): BSFrozen {
    return this  
  }

  compile(): pulumi.Output<string> {
    return pulumi.output(this.lines).apply(v => v.join("\n"))
  }
}